launcher: notice when an interrupted apply was finished or rolled back (retries while another window applies), windows take over config changes made in another window, hints and settings wrap at the minimum window size, data paths with .. cleaned up, boot-to-level starts bound to their level (ignored for other levels)
This commit is contained in:
1 parent
8b78958306
commit
c4fa500198
30 files changed
+695
-102
No files matched your search
@@ -23,6 +23,10 @@ first.
|
||||
* Mods marked **modifies groove.exe** add code to the game program of LlamaMod's copy. This is
|
||||
safe, but on Windows some antivirus programs flag the changed file – see
|
||||
[Troubleshooting](20-troubleshooting.md).
|
||||
* If LlamaMod was closed in the middle of an Apply, it repairs its game copy at the next start
|
||||
and tells you whether it finished that Apply or went back to the previous mod selection.
|
||||
* Two LlamaMod windows (or the level editor) share one mod selection: a window shows the other's
|
||||
changes as soon as you click into it.
|
||||
|
||||

|
||||
|
||||
@@ -97,7 +101,7 @@ Play starts the game straight in the chosen level – no logos, no menus, no wor
|
||||
| Save slot | 1 – 3 | 3 | The save game used for lives and progress. The game also saves into it. |
|
||||
| Skip startup logos | on / off | on | Also skips the logo movies and the title screen. |
|
||||
| Escape quits the game | on / off | off | Escape ends the game at once instead of opening the pause menu. |
|
||||
| Player start | text | (empty) | Leave empty for the level's own start. Advanced: "x, y, z[, angle]" in cells moves the start. |
|
||||
| Player start | text | (empty) | Leave empty for the level's own start. Advanced: "x, y, z[, angle]" in cells moves the start; "… @ world/chapter" uses it only in that level. |
|
||||
|
||||
#### Intro skip
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ Test World with all mods; boot-to-level + all exe mods (River loading card); pra
|
||||
on-demand files are fragile; the wizard could warn when the chosen folder is under `%OneDrive%`.
|
||||
* ~~**Interrupted apply**~~ – fixed 2026-11-07: `cd-active.new.complete` marker +
|
||||
`ModManager.recoverInterruptedApply()` (GUI start, Play, `--launch`), see llamamod-app.md.
|
||||
Follow-up 2026-11-07: the GUI tells the user once what was repaired and retries when another
|
||||
window held the apply lock (was silently skipped).
|
||||
* ~~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`).
|
||||
Follow-up: the window rebuilds its Mods/Settings tabs after a merge or when activated with a
|
||||
changed config.json (`Config.refresh`), so its check boxes no longer show stale values.
|
||||
* ~~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: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
+15
-1
@@ -73,6 +73,15 @@ instance last loaded/saved: fields it did not change keep what another window/CL
|
||||
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).
|
||||
`save` returns true when the merge pulled in values written elsewhere; `Config.refresh(file)` does
|
||||
the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes
|
||||
when its window is activated and after a save that pulled something in, then rebuilds the Mods and
|
||||
Settings tabs (status "Took over changes made in another LlamaMod window."), so a stale window's
|
||||
next click cannot undo another window's selection.
|
||||
Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left
|
||||
window after a click into it), `img/app/interrupted-apply-notice.jpg`, `img/app/saves-min-width.jpg`,
|
||||
`img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the
|
||||
Settings tab tracks the viewport width, the header subtitle is shortened in the middle before the buttons).
|
||||
|
||||
## First start
|
||||
|
||||
@@ -157,7 +166,12 @@ after the swap. If the app is killed between the two renames, `ModManager.recove
|
||||
(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)
|
||||
restores the original as before). It returns a `Recovery`: NONE, FINISHED (new folder moved in),
|
||||
RESTORED (old folder back), BUSY (another window/CLI holds `.apply.lock`: `BusyException`), FAILED.
|
||||
The launcher shows FINISHED/RESTORED once (status line + small dialog: "The last apply was
|
||||
interrupted; LlamaMod finished it." / "…restored the previous mod selection."); after BUSY it checks
|
||||
again every 3 s and on window activation while idle, and Play reports the lock instead of starting
|
||||
(`ui/RecoveryNotice`). CLI `--launch` prints the same texts. 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`.
|
||||
|
||||
@@ -10,7 +10,16 @@ no world map. Used by the level editor's Play button (details, offsets and tests
|
||||
| `slot` | 3 | save slot the game plays with (new game if empty) |
|
||||
| `logos` | on | skip logo movies and title screen (skipped if intro-skip already patched them) |
|
||||
| `quitOnEscape` | off | Escape (pause) ends the game at once through the normal exit path |
|
||||
| `start` | empty | "x, y, z[, yaw]" in cells: moves the level's player start (WAD rebuild) |
|
||||
| `start` | empty | "x, y, z[, yaw][ @ world/chapter]" in cells: moves the level's player start (WAD rebuild) |
|
||||
|
||||
**Start bound to a level** (2026-11-07): a start with "@ world/chapter"
|
||||
(`BootToLevelMod.formatStart(world, chapter, …)`) is used only while `world`/`chapter` name that
|
||||
level; for any other level it is ignored with a progress note ("Player start ignored: it was set
|
||||
for world W chapter C") instead of failing with "no collision floor" (a play-test whose restore
|
||||
never ran left its start in config.json, and every later apply for another level failed). A start
|
||||
without "@" applies to whatever level is chosen (old configs, hand-typed values) and is still
|
||||
checked strictly. Tests: `BootToLevelModTest.startOfAnotherLevelIsIgnored`, `parsesStartLevel`.
|
||||
The editor's play-test (`PlaytestRunner.install`) should write the bound form – see the report.
|
||||
|
||||
Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load
|
||||
slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3,
|
||||
|
||||
@@ -184,8 +184,12 @@ public final class Main {
|
||||
requireInstalled(cfg);
|
||||
new SaveStore(paths).migrateNullSaves().forEach(System.out::println);
|
||||
ModManager mm = new ModManager(paths, cfg);
|
||||
if (mm.recoverInterruptedApply()) {
|
||||
System.out.println("Repaired the CD folder of an interrupted apply.");
|
||||
switch (mm.recoverInterruptedApply()) {
|
||||
case FINISHED -> System.out.println("The last apply was interrupted; LlamaMod finished it.");
|
||||
case RESTORED -> System.out.println(
|
||||
"The last apply was interrupted; LlamaMod restored the previous mod selection.");
|
||||
case BUSY -> throw new ModManager.BusyException();
|
||||
default -> {}
|
||||
}
|
||||
checkExe(mm, paths);
|
||||
GameLauncher gl = new GameLauncher(paths, cfg);
|
||||
|
||||
@@ -364,6 +364,14 @@ public final class ModManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Another window, the CLI or the editor's play-test holds the apply lock. */
|
||||
public static final class BusyException extends IOException {
|
||||
public BusyException() {
|
||||
super("Another LlamaMod window or command is applying mods right now."
|
||||
+ " Please wait until it has finished and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusive lock on the data folder while cd-active, the cache and game/groove.exe are
|
||||
* rebuilt: a second LlamaMod window, the CLI or the editor's play-test would otherwise delete
|
||||
@@ -385,8 +393,7 @@ public final class ModManager {
|
||||
}
|
||||
if (l == null) {
|
||||
ch.close();
|
||||
throw new IOException("Another LlamaMod window or command is applying mods right now."
|
||||
+ " Please wait until it has finished and try again.");
|
||||
throw new BusyException();
|
||||
}
|
||||
return new DataLock(ch, l);
|
||||
}
|
||||
@@ -711,48 +718,75 @@ public final class ModManager {
|
||||
return paths.cdActive().resolveSibling("cd-active.new.complete");
|
||||
}
|
||||
|
||||
/** Outcome of {@link #recoverInterruptedApply()}. */
|
||||
public enum Recovery {
|
||||
/** Nothing was interrupted. */
|
||||
NONE,
|
||||
/** The complete new folder was moved in: the interrupted apply is finished. */
|
||||
FINISHED,
|
||||
/** The new folder was unusable: the previous mod selection is back. */
|
||||
RESTORED,
|
||||
/** Another window or command holds the apply lock: try again later. */
|
||||
BUSY,
|
||||
/** Neither folder is usable; the next apply or Play builds cd-active again. */
|
||||
FAILED;
|
||||
|
||||
public boolean repaired() {
|
||||
return this == FINISHED || this == RESTORED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which leftover folder replaces a missing {@code cd-active}: {@code cd-active.new} only if
|
||||
* it is complete ({@code built} = its marker, null if none) and built for the selection
|
||||
* config.json records; otherwise {@code cd-active.old}, the folder of that selection.
|
||||
*/
|
||||
static Recovery chooseRecovery(String built, String recorded, boolean newExists, boolean oldExists) {
|
||||
if (built != null && built.equals(recorded) && newExists) {
|
||||
return Recovery.FINISHED;
|
||||
}
|
||||
return oldExists ? Recovery.RESTORED : Recovery.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* renames (see {@link #chooseRecovery}); config.json's activeKey matches game/groove.exe
|
||||
* because both are written only after the swap.
|
||||
*/
|
||||
public boolean recoverInterruptedApply() {
|
||||
public Recovery recoverInterruptedApply() {
|
||||
Path active = paths.cdActive();
|
||||
if (Files.exists(active)) {
|
||||
return false;
|
||||
return Recovery.NONE;
|
||||
}
|
||||
Path next = active.resolveSibling("cd-active.new");
|
||||
Path old = active.resolveSibling("cd-active.old");
|
||||
if (!Files.isDirectory(next) && !Files.isDirectory(old)) {
|
||||
return false;
|
||||
return Recovery.NONE;
|
||||
}
|
||||
// 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;
|
||||
return Recovery.NONE;
|
||||
}
|
||||
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 {
|
||||
Recovery r = chooseRecovery(built, recorded, Files.isDirectory(next), Files.isDirectory(old));
|
||||
if (r == Recovery.FAILED) {
|
||||
System.err.println("interrupted apply: cd-active missing, cd-active.new "
|
||||
+ (built == null ? "incomplete" : "built for another selection") + " – not recovered");
|
||||
return false;
|
||||
return r;
|
||||
}
|
||||
Path use = r == Recovery.FINISHED ? next : old;
|
||||
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;
|
||||
return r;
|
||||
} catch (BusyException e) {
|
||||
return Recovery.BUSY;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
return Recovery.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ public final class BootToLevelMod implements Mod {
|
||||
"start",
|
||||
"Player start",
|
||||
"Leave empty for the level's own start. Advanced: \"x, y, z[, angle]\" in cells moves the"
|
||||
+ " start.",
|
||||
+ " start; \"… @ world/chapter\" uses it only in that level.",
|
||||
""));
|
||||
}
|
||||
|
||||
@@ -118,7 +118,14 @@ public final class BootToLevelMod implements Mod {
|
||||
.noneMatch(p -> p.toLowerCase(Locale.ROOT).startsWith("wads/t1l9m"))) {
|
||||
throw new IOException("No TestLevel is installed (enable a level mod for world 9 first)");
|
||||
}
|
||||
double[] start = parseStart(ctx.option("start"));
|
||||
String startText = ctx.option("start");
|
||||
double[] start = parseStart(startText);
|
||||
int[] bound = startLevel(startText);
|
||||
if (start != null && bound != null && (bound[0] != world || bound[1] != chapter)) {
|
||||
// left over from a play-test of another level: that level's coordinates mean nothing here
|
||||
ctx.progress(0, "Player start ignored: it was set for world " + bound[0] + " chapter " + bound[1]);
|
||||
start = null;
|
||||
}
|
||||
if (start != null) {
|
||||
if (!ctx.exists(wad)) {
|
||||
throw new IOException("Player start override: " + wad + " does not exist");
|
||||
@@ -186,12 +193,20 @@ public final class BootToLevelMod implements Mod {
|
||||
return String.format(Locale.ROOT, "wads/T1L%dM%03d.wad", world, chapter);
|
||||
}
|
||||
|
||||
/** "x, y, z[, yaw]" → {x, y, z, yaw or NaN}; empty → null. */
|
||||
/**
|
||||
* "x, y, z[, yaw][ @ world/chapter]" → {x, y, z, yaw or NaN}; empty → null. The level part
|
||||
* (see {@link #startLevel}) is not part of the position.
|
||||
*/
|
||||
static double[] parseStart(String s) throws IOException {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String[] p = s.trim().split("\\s*[,; ]\\s*");
|
||||
int at = s.indexOf('@');
|
||||
String pos = at < 0 ? s : s.substring(0, at);
|
||||
if (pos.isBlank()) {
|
||||
throw new IOException("Player start must be \"x, y, z\" or \"x, y, z, yaw\": " + s);
|
||||
}
|
||||
String[] p = pos.trim().split("\\s*[,; ]\\s*");
|
||||
if (p.length < 3 || p.length > 4) {
|
||||
throw new IOException("Player start must be \"x, y, z\" or \"x, y, z, yaw\": " + s);
|
||||
}
|
||||
@@ -206,6 +221,31 @@ public final class BootToLevelMod implements Mod {
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level a start is bound to ("… @ 3/2" = world 3, chapter 2), or null for a start without
|
||||
* one (applies to whatever level is chosen).
|
||||
*/
|
||||
static int[] startLevel(String s) throws IOException {
|
||||
int at = s == null ? -1 : s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return null;
|
||||
}
|
||||
String[] p = s.substring(at + 1).trim().split("\\s*/\\s*");
|
||||
try {
|
||||
if (p.length == 2) {
|
||||
return new int[] {Integer.parseInt(p[0]), Integer.parseInt(p[1])};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// fall through
|
||||
}
|
||||
throw new IOException("Player start: the level after @ must be \"world/chapter\": " + s);
|
||||
}
|
||||
|
||||
/** A start bound to one level: ignored (with a note) when another level is chosen. */
|
||||
public static String formatStart(int world, int chapter, double x, double y, double z, double yaw) {
|
||||
return formatStart(x, y, z, yaw) + " @ " + world + "/" + chapter;
|
||||
}
|
||||
|
||||
public static String formatStart(double x, double y, double z, double yaw) {
|
||||
return Double.isNaN(yaw)
|
||||
? String.format(Locale.ROOT, "%.3f, %.3f, %.3f", x, y, z)
|
||||
|
||||
@@ -19,6 +19,14 @@ import java.util.prefs.Preferences;
|
||||
public record AppPaths(Path root) {
|
||||
private static final String PREF_KEY = "dataDir";
|
||||
|
||||
/**
|
||||
* Without "..": the installer compares normalized file paths with the root ("Set up again"
|
||||
* failed with "Invalid path in source" for LLAMAMOD_DATA=/x/y/../data).
|
||||
*/
|
||||
public AppPaths {
|
||||
root = root.normalize();
|
||||
}
|
||||
|
||||
public static Path defaultRoot() {
|
||||
if (Os.isWindows()) {
|
||||
String la = System.getenv("LOCALAPPDATA");
|
||||
|
||||
@@ -144,22 +144,52 @@ public final class Config {
|
||||
* 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.
|
||||
*
|
||||
* @return true if that pulled in values written elsewhere (views showing this config are stale)
|
||||
*/
|
||||
public void save(Path file) throws IOException {
|
||||
public boolean 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));
|
||||
Map<String, Object> mine = snapshot();
|
||||
Map<String, Object> merged = merge(base, mine, readMap(file));
|
||||
read(merged);
|
||||
FileOps.writeAtomic(file, Json.write(merged).getBytes(StandardCharsets.UTF_8));
|
||||
base = snapshot();
|
||||
return !base.equals(mine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over what another window or command saved since this instance last loaded or saved
|
||||
* the file, keeping this instance's unsaved changes (same merge as {@link #save}, nothing is
|
||||
* written).
|
||||
*
|
||||
* @return true if any value changed
|
||||
*/
|
||||
public boolean refresh(Path file) {
|
||||
synchronized (Config.class) {
|
||||
Map<String, Object> disk = readMap(file);
|
||||
if (base == null || disk == null) {
|
||||
return false;
|
||||
}
|
||||
Config onDisk = new Config();
|
||||
onDisk.read(disk);
|
||||
Map<String, Object> diskBase = onDisk.snapshot();
|
||||
if (diskBase.equals(base)) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Object> mine = snapshot();
|
||||
read(merge(base, mine, disk));
|
||||
base = diskBase;
|
||||
return !snapshot().equals(mine);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> readMap(Path file) {
|
||||
try {
|
||||
if (Files.isRegularFile(file)
|
||||
|
||||
@@ -49,7 +49,7 @@ public final class App {
|
||||
|
||||
/** Explanation below or beside a control, wrapped at {@code width} (trusted HTML). */
|
||||
static JLabel hintLabel(String html, int width, float fontSize) {
|
||||
JLabel l = new JLabel(MainFrame.html(html, width));
|
||||
JLabel l = new WrapLabel(html, width);
|
||||
l.setFont(l.getFont().deriveFont(fontSize));
|
||||
l.setForeground(hintColor());
|
||||
return l;
|
||||
|
||||
@@ -20,6 +20,8 @@ final class AppState {
|
||||
GameLauncher launcher;
|
||||
SaveStore saves;
|
||||
ModManager modManager;
|
||||
/** Result of the interrupted-apply check at start (or after a data folder change), not yet shown. */
|
||||
ModManager.Recovery recovery = ModManager.Recovery.NONE;
|
||||
|
||||
AppState(AppPaths paths) {
|
||||
setPaths(paths);
|
||||
@@ -31,9 +33,7 @@ final class AppState {
|
||||
launcher = new GameLauncher(p, cfg);
|
||||
saves = new SaveStore(p);
|
||||
modManager = new ModManager(p, cfg);
|
||||
if (cfg.installed()) {
|
||||
modManager.recoverInterruptedApply();
|
||||
}
|
||||
recovery = cfg.installed() ? modManager.recoverInterruptedApply() : ModManager.Recovery.NONE;
|
||||
}
|
||||
|
||||
void reloadMods() {
|
||||
@@ -48,7 +48,13 @@ final class AppState {
|
||||
.toList();
|
||||
}
|
||||
|
||||
void saveConfig() throws IOException {
|
||||
cfg.save(paths.config());
|
||||
/** True if the save pulled in changes of another window (see {@link Config#save}). */
|
||||
boolean saveConfig() throws IOException {
|
||||
return cfg.save(paths.config());
|
||||
}
|
||||
|
||||
/** Takes over config.json changes of other windows; true if any value changed. */
|
||||
boolean refreshConfig() {
|
||||
return cfg.refresh(paths.config());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.eliasmueller.llamamod.ui;
|
||||
import com.eliasmueller.llamamod.format.CorImage;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.GradientPaint;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
@@ -12,6 +15,7 @@ import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
@@ -94,8 +98,46 @@ final class HeaderPanel extends JPanel {
|
||||
if (!subtitle.isEmpty()) {
|
||||
g.setFont(base.deriveFont(Font.PLAIN, 12.5f));
|
||||
g.setColor(new Color(255, 255, 255, 200));
|
||||
g.drawString(subtitle, x, y3);
|
||||
g.drawString(elide(subtitle, g.getFontMetrics(), textRight() - x), x, y3);
|
||||
}
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
/** Left edge of the buttons on the header, minus a gap (the subtitle must not run under them). */
|
||||
private int textRight() {
|
||||
int right = getWidth();
|
||||
for (Component c : getComponents()) {
|
||||
if (c instanceof Container box && box.getComponentCount() > 0 && !(c instanceof JButton)) {
|
||||
for (Component b : box.getComponents()) {
|
||||
if (b.isVisible() && b.getWidth() > 0) {
|
||||
right = Math.min(right, c.getX() + b.getX());
|
||||
}
|
||||
}
|
||||
} else if (c.isVisible()) {
|
||||
right = Math.min(right, c.getX());
|
||||
}
|
||||
}
|
||||
return right - 16;
|
||||
}
|
||||
|
||||
/** Cuts the middle out of {@code s} (keeping the end of a path) until it fits {@code width}. */
|
||||
static String elide(String s, FontMetrics fm, int width) {
|
||||
if (fm.stringWidth(s) <= width) {
|
||||
return s;
|
||||
}
|
||||
int keepStart = s.length() / 2;
|
||||
int keepEnd = s.length() - keepStart;
|
||||
while (keepStart > 0 || keepEnd > 0) {
|
||||
if (keepStart >= keepEnd && keepStart > 0) {
|
||||
keepStart--;
|
||||
} else {
|
||||
keepEnd--;
|
||||
}
|
||||
String t = s.substring(0, keepStart) + "…" + s.substring(s.length() - keepEnd);
|
||||
if (fm.stringWidth(t) <= width) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return "…";
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ final class MainFrame extends JFrame {
|
||||
private boolean gameRunning;
|
||||
private boolean crashReported;
|
||||
private boolean oldWineAccepted;
|
||||
private final RecoveryNotice recoveryNotice = new RecoveryNotice();
|
||||
private boolean recoveryRunning;
|
||||
|
||||
MainFrame(AppState state) {
|
||||
super("LlamaMod");
|
||||
@@ -83,6 +85,12 @@ final class MainFrame extends JFrame {
|
||||
dispose();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowActivated(WindowEvent e) {
|
||||
pullConfig();
|
||||
retryRecovery();
|
||||
}
|
||||
});
|
||||
setIconImages(icons());
|
||||
buildUi();
|
||||
@@ -96,7 +104,11 @@ final class MainFrame extends JFrame {
|
||||
});
|
||||
setLocationRelativeTo(null);
|
||||
refreshAll();
|
||||
new Timer(3000, e -> updateGameRunning()).start();
|
||||
new Timer(3000, e -> {
|
||||
updateGameRunning();
|
||||
retryRecovery();
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
static List<Image> icons() {
|
||||
@@ -202,6 +214,77 @@ final class MainFrame extends JFrame {
|
||||
settingsPanel.reload();
|
||||
updateGameRunning();
|
||||
setStatus(inst ? Strings.t("status.ready") : Strings.t("status.notInstalled"));
|
||||
if (state.recovery != ModManager.Recovery.NONE) {
|
||||
showRecovery(state.recovery);
|
||||
state.recovery = ModManager.Recovery.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the user once that an interrupted apply was repaired; BUSY is retried later. */
|
||||
private void showRecovery(ModManager.Recovery r) {
|
||||
String key = recoveryNotice.handle(r);
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
setStatus(Strings.t(key));
|
||||
SwingUtilities.invokeLater(
|
||||
() -> JOptionPane.showMessageDialog(this, Strings.t(key), "LlamaMod", JOptionPane.INFORMATION_MESSAGE));
|
||||
}
|
||||
|
||||
/** After another window held the apply lock: checks again once nothing runs here. */
|
||||
private void retryRecovery() {
|
||||
if (!recoveryNotice.needsRetry() || recoveryRunning || busy()) {
|
||||
return;
|
||||
}
|
||||
recoveryRunning = true;
|
||||
var mm = state.modManager;
|
||||
new Thread(
|
||||
() -> {
|
||||
ModManager.Recovery r = mm.recoverInterruptedApply();
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
recoveryRunning = false;
|
||||
showRecovery(r);
|
||||
});
|
||||
},
|
||||
"recover-apply")
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over config.json changes another window saved (mod selection, options, settings) so
|
||||
* the next click here does not undo them.
|
||||
*/
|
||||
private void pullConfig() {
|
||||
if (busy() || !state.cfg.installed()) {
|
||||
return;
|
||||
}
|
||||
String theme = state.cfg.theme;
|
||||
if (state.refreshConfig()) {
|
||||
configChangedElsewhere(theme);
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves config.json; if that merged in values of another window, shows them. */
|
||||
void saveConfig() {
|
||||
String theme = state.cfg.theme;
|
||||
try {
|
||||
if (state.saveConfig()) {
|
||||
// later: the caller may be in the middle of an event of a component the reload replaces
|
||||
SwingUtilities.invokeLater(() -> configChangedElsewhere(theme));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void configChangedElsewhere(String themeBefore) {
|
||||
if (!state.cfg.theme.equals(themeBefore)) {
|
||||
reloadTheme();
|
||||
return;
|
||||
}
|
||||
modsPanel.reload();
|
||||
settingsPanel.reload();
|
||||
setStatus(Strings.t("status.configMerged"));
|
||||
}
|
||||
|
||||
void onBusy(Consumer<Boolean> l) {
|
||||
@@ -498,7 +581,11 @@ final class MainFrame extends JFrame {
|
||||
Strings.t("task.launch"),
|
||||
p -> {
|
||||
state.saves.migrateNullSaves();
|
||||
state.modManager.recoverInterruptedApply();
|
||||
ModManager.Recovery rec = state.modManager.recoverInterruptedApply();
|
||||
SwingUtilities.invokeLater(() -> showRecovery(rec));
|
||||
if (rec == ModManager.Recovery.BUSY) {
|
||||
throw new ModManager.BusyException();
|
||||
}
|
||||
if (!Files.isDirectory(state.paths.cdActive())) {
|
||||
state.modManager.restoreOriginal(p.sub(0, 0.3));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import java.awt.GridBagLayout;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.IOException;
|
||||
@@ -44,7 +43,6 @@ import javax.swing.JScrollPane;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
@@ -78,7 +76,7 @@ final class ModsPanel extends JPanel {
|
||||
add(intro, BorderLayout.NORTH);
|
||||
|
||||
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
|
||||
JPanel holder = new WidthTrackingPanel();
|
||||
JPanel holder = new WidthTrackingPanel(new BorderLayout());
|
||||
holder.add(list, BorderLayout.NORTH);
|
||||
scroll = new JScrollPane(holder);
|
||||
scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
@@ -105,38 +103,6 @@ final class ModsPanel extends JPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/** Lays the cards out at the viewport width (text wraps instead of scrolling sideways). */
|
||||
private static final class WidthTrackingPanel extends JPanel implements Scrollable {
|
||||
WidthTrackingPanel() {
|
||||
super(new BorderLayout());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle r, int orientation, int direction) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle r, int orientation, int direction) {
|
||||
return r.height - 32;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateEnabled() {
|
||||
boolean on = !locked && frame.state.cfg.installed();
|
||||
applyButton.setEnabled(on);
|
||||
@@ -642,11 +608,7 @@ final class ModsPanel extends JPanel {
|
||||
}
|
||||
|
||||
private void saveConfig() {
|
||||
try {
|
||||
frame.state.saveConfig();
|
||||
} catch (Exception ex) {
|
||||
frame.error(ex);
|
||||
}
|
||||
frame.saveConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import com.eliasmueller.llamamod.mods.ModManager.Recovery;
|
||||
|
||||
/** What the window does with the result of an interrupted-apply check. */
|
||||
final class RecoveryNotice {
|
||||
private boolean retry;
|
||||
|
||||
/** Message key to show the user once, or null; remembers whether to try again later. */
|
||||
String handle(Recovery r) {
|
||||
retry = r == Recovery.BUSY;
|
||||
return switch (r) {
|
||||
case FINISHED -> "recover.finished";
|
||||
case RESTORED -> "recover.restored";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Another window held the apply lock at the last check. */
|
||||
boolean needsRetry() {
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ final class SavesPanel extends JPanel {
|
||||
g.gridy++;
|
||||
g.weighty = 0;
|
||||
g.fill = GridBagConstraints.HORIZONTAL;
|
||||
ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 560, 11.5f), g);
|
||||
ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g);
|
||||
add(ed, BorderLayout.CENTER);
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
|
||||
@@ -51,16 +51,16 @@ final class SettingsPanel extends JPanel {
|
||||
private final JTextField wine = new JTextField(24);
|
||||
private final JComboBox<String> theme =
|
||||
new JComboBox<>(new String[] {Strings.t("settings.theme.light"), Strings.t("settings.theme.dark")});
|
||||
private final JLabel dataDir = new JLabel();
|
||||
private final JLabel dataDir = new WrapLabel("", 2000);
|
||||
private final JLabel cacheInfo = new JLabel();
|
||||
private final JLabel wineInfo = new JLabel();
|
||||
private final JLabel wineInfo = new WrapLabel("", 640);
|
||||
private boolean loading;
|
||||
|
||||
SettingsPanel(MainFrame frame) {
|
||||
super(new BorderLayout());
|
||||
this.frame = frame;
|
||||
setBorder(BorderFactory.createEmptyBorder(16, 20, 12, 20));
|
||||
JPanel p = new JPanel(new GridBagLayout());
|
||||
JPanel p = new WidthTrackingPanel(new GridBagLayout());
|
||||
int y = 0;
|
||||
|
||||
resolution.addItem(Strings.t("settings.auto"));
|
||||
@@ -178,14 +178,19 @@ final class SettingsPanel extends JPanel {
|
||||
g.gridx = 1;
|
||||
g.weightx = 1;
|
||||
g.insets = new Insets(4, 0, 4, 0);
|
||||
JPanel w = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||
w.add(c);
|
||||
p.add(w, g);
|
||||
g.fill = GridBagConstraints.HORIZONTAL;
|
||||
if (c instanceof WrapLabel) {
|
||||
p.add(c, g);
|
||||
} else {
|
||||
JPanel w = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||
w.add(c);
|
||||
p.add(w, g);
|
||||
}
|
||||
y++;
|
||||
if (hint != null) {
|
||||
g.gridy = y;
|
||||
g.insets = new Insets(0, 0, 4, 0);
|
||||
p.add(App.hintLabel(hint, 520, 11.5f), g);
|
||||
p.add(App.hintLabel(hint, 620, 11.5f), g);
|
||||
y++;
|
||||
}
|
||||
return y;
|
||||
@@ -206,7 +211,7 @@ final class SettingsPanel extends JPanel {
|
||||
virtualDesktop.setSelected(cfg.virtualDesktop);
|
||||
wine.setText(cfg.wineCommand);
|
||||
theme.setSelectedIndex("dark".equals(cfg.theme) ? 1 : 0);
|
||||
dataDir.setText(frame.state.paths.root().toString());
|
||||
dataDir.setText(breakablePath(frame.state.paths.root().toString()));
|
||||
GameLauncher.Resolution auto = GameLauncher.autoResolution(!Os.isWindows() && cfg.virtualDesktop);
|
||||
resolution.setToolTipText(Strings.t("settings.autoIs", auto.w() + "x" + auto.h()));
|
||||
// show what "Automatic" means right in the list
|
||||
@@ -222,7 +227,7 @@ final class SettingsPanel extends JPanel {
|
||||
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(
|
||||
wineInfo.setText(MainFrame.escape(
|
||||
w == null
|
||||
? Strings.t("settings.wineMissing")
|
||||
: !ok
|
||||
@@ -231,13 +236,18 @@ final class SettingsPanel extends JPanel {
|
||||
? Strings.t("settings.wineTooOld", w, version, min)
|
||||
: version != null
|
||||
? Strings.t("settings.wineFoundVersion", w, version)
|
||||
: Strings.t("settings.wineFound", w));
|
||||
: Strings.t("settings.wineFound", w)));
|
||||
wineInfo.setForeground(ok && !tooOld ? UIManager.getColor("Label.foreground") : App.ERROR);
|
||||
}
|
||||
updateCache();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/** HTML for a long path that may wrap after its separators. */
|
||||
static String breakablePath(String path) {
|
||||
return MainFrame.escape(path).replaceAll("([/\\\\])", "$1\u200B");
|
||||
}
|
||||
|
||||
private void updateCache() {
|
||||
try {
|
||||
long b = frame.state.modManager.cacheSize();
|
||||
@@ -271,11 +281,7 @@ final class SettingsPanel extends JPanel {
|
||||
cfg.virtualDesktop = virtualDesktop.isSelected();
|
||||
cfg.wineCommand = wine.getText().trim();
|
||||
cfg.theme = theme.getSelectedIndex() == 1 ? "dark" : "light";
|
||||
try {
|
||||
frame.state.saveConfig();
|
||||
} catch (Exception e) {
|
||||
frame.error(e);
|
||||
}
|
||||
frame.saveConfig();
|
||||
}
|
||||
|
||||
private void clearCache() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import java.awt.Dimension;
|
||||
import java.awt.LayoutManager;
|
||||
import java.awt.Rectangle;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.Scrollable;
|
||||
|
||||
/** Scroll pane content laid out at the viewport width: text wraps instead of scrolling sideways. */
|
||||
final class WidthTrackingPanel extends JPanel implements Scrollable {
|
||||
WidthTrackingPanel(LayoutManager layout) {
|
||||
super(layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle r, int orientation, int direction) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle r, int orientation, int direction) {
|
||||
return r.height - 32;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Insets;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.border.Border;
|
||||
import javax.swing.plaf.basic.BasicHTML;
|
||||
import javax.swing.text.View;
|
||||
|
||||
/**
|
||||
* Label with trusted HTML that wraps at the width its container gives it (lines at most
|
||||
* {@code maxWidth} long) and asks for almost no width itself, so it never forces a wider window
|
||||
* or a horizontal scroll bar. Give it a horizontal fill in its layout.
|
||||
*/
|
||||
final class WrapLabel extends JLabel {
|
||||
private final int maxWidth;
|
||||
private Border base;
|
||||
private int laidOutWidth = -1;
|
||||
|
||||
WrapLabel(String html, int maxWidth) {
|
||||
this.maxWidth = maxWidth;
|
||||
setText(html);
|
||||
setVerticalAlignment(TOP);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setText(String html) {
|
||||
super.setText(html == null || html.isEmpty() ? html : "<html>" + html + "</html>");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBorder(Border b) {
|
||||
base = b;
|
||||
super.setBorder(b);
|
||||
}
|
||||
|
||||
private Insets baseInsets() {
|
||||
return base == null ? new Insets(0, 0, 0, 0) : base.getBorderInsets(this);
|
||||
}
|
||||
|
||||
/** Text width: the current width, at most maxWidth, before the first layout maxWidth. */
|
||||
private int textWidth() {
|
||||
Insets in = baseInsets();
|
||||
int w = getWidth() > 0 ? getWidth() - in.left - in.right : maxWidth;
|
||||
return Math.max(20, Math.min(w, maxWidth));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
View v = (View) getClientProperty(BasicHTML.propertyKey);
|
||||
if (v == null || isPreferredSizeSet()) {
|
||||
return super.getPreferredSize();
|
||||
}
|
||||
Insets in = baseInsets();
|
||||
int tw = textWidth();
|
||||
v.setSize(tw, 0);
|
||||
int natural = (int) Math.ceil(v.getPreferredSpan(View.X_AXIS));
|
||||
int h = (int) Math.ceil(v.getPreferredSpan(View.Y_AXIS));
|
||||
return new Dimension(Math.min(natural, 60) + in.left + in.right, h + in.top + in.bottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMinimumSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBounds(int x, int y, int w, int h) {
|
||||
super.setBounds(x, y, w, h);
|
||||
if (w != laidOutWidth) {
|
||||
laidOutWidth = w;
|
||||
// lines end at maxWidth even in a wider cell
|
||||
Insets in = baseInsets();
|
||||
int extra = Math.max(0, w - in.left - in.right - maxWidth);
|
||||
Border pad = BorderFactory.createEmptyBorder(0, 0, 0, extra);
|
||||
super.setBorder(base == null ? pad : BorderFactory.createCompoundBorder(base, pad));
|
||||
if (getPreferredSize().height != h) {
|
||||
SwingUtilities.invokeLater(this::revalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ status.failed={0} – failed.
|
||||
status.cancelled=Cancelled.
|
||||
status.gameRunning=The game is running.
|
||||
status.gameEnded=The game has ended.
|
||||
status.configMerged=Took over changes made in another LlamaMod window.
|
||||
recover.finished=The last apply was interrupted; LlamaMod finished it.
|
||||
recover.restored=The last apply was interrupted; LlamaMod restored the previous mod selection.
|
||||
|
||||
task.apply=Applying mods
|
||||
task.restore=Restoring original
|
||||
|
||||
@@ -8,8 +8,11 @@ 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.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -78,12 +81,12 @@ class InterruptedApplyTest {
|
||||
Files.move(paths.cdActive(), next);
|
||||
Files.writeString(marker, key);
|
||||
folder(old, 9);
|
||||
assertTrue(mm.recoverInterruptedApply());
|
||||
assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply());
|
||||
assertEquals(2, wad());
|
||||
assertFalse(Files.exists(next));
|
||||
assertFalse(Files.exists(old));
|
||||
assertFalse(Files.exists(marker));
|
||||
assertFalse(mm.recoverInterruptedApply(), "nothing left to repair");
|
||||
assertEquals(ModManager.Recovery.NONE, mm.recoverInterruptedApply(), "nothing left to repair");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,7 +97,7 @@ class InterruptedApplyTest {
|
||||
Files.move(paths.cdActive(), old);
|
||||
folder(next, 2);
|
||||
Files.writeString(marker, mm.selectionKey(List.of(new Two())));
|
||||
assertTrue(mm.recoverInterruptedApply());
|
||||
assertEquals(ModManager.Recovery.RESTORED, mm.recoverInterruptedApply());
|
||||
assertEquals(1, wad());
|
||||
assertFalse(Files.exists(next));
|
||||
assertFalse(Files.exists(marker));
|
||||
@@ -105,7 +108,7 @@ class InterruptedApplyTest {
|
||||
ModManager mm = install();
|
||||
folder(next, 1);
|
||||
Files.writeString(marker, ""); // restoring the original; config.json activeKey is "" too
|
||||
assertTrue(mm.recoverInterruptedApply());
|
||||
assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply());
|
||||
assertEquals(1, wad());
|
||||
}
|
||||
|
||||
@@ -113,11 +116,40 @@ class InterruptedApplyTest {
|
||||
void incompleteNewFolderIsNotUsed() throws Exception {
|
||||
ModManager mm = install();
|
||||
folder(next, 2); // killed while building: no marker
|
||||
assertFalse(mm.recoverInterruptedApply());
|
||||
assertEquals(ModManager.Recovery.FAILED, 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));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedByAnotherWindowIsReportedAsBusyAndRetriedLater() throws Exception {
|
||||
ModManager mm = install();
|
||||
folder(next, 1);
|
||||
Files.writeString(marker, "");
|
||||
try (FileChannel ch = FileChannel.open(
|
||||
tmp.resolve(".apply.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
FileLock l = ch.lock()) {
|
||||
assertEquals(ModManager.Recovery.BUSY, mm.recoverInterruptedApply());
|
||||
assertFalse(Files.exists(paths.cdActive()), "nothing touched while locked");
|
||||
}
|
||||
assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply());
|
||||
assertEquals(1, wad());
|
||||
}
|
||||
|
||||
@Test
|
||||
void chooseRecovery() {
|
||||
assertEquals(ModManager.Recovery.FINISHED, ModManager.chooseRecovery("k", "k", true, true));
|
||||
assertEquals(ModManager.Recovery.FINISHED, ModManager.chooseRecovery("k", "k", true, false));
|
||||
assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery("other", "k", true, true));
|
||||
assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery(null, "k", true, true));
|
||||
assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery("k", "k", false, true));
|
||||
assertEquals(ModManager.Recovery.FAILED, ModManager.chooseRecovery(null, "k", true, false));
|
||||
assertEquals(ModManager.Recovery.FAILED, ModManager.chooseRecovery("other", "k", true, false));
|
||||
assertTrue(ModManager.Recovery.FINISHED.repaired());
|
||||
assertTrue(ModManager.Recovery.RESTORED.repaired());
|
||||
assertFalse(ModManager.Recovery.BUSY.repaired());
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,14 @@ class BootToLevelModTest {
|
||||
|
||||
/** TestModContext turns empty values into "1"; the start option must stay empty. */
|
||||
static ModContext ctx(BootToLevelMod m, Map<String, String> o, ExeImage exe) throws IOException {
|
||||
return ctx(m, o, exe, new java.util.ArrayList<>());
|
||||
}
|
||||
|
||||
/** {@code log} receives "write <path>" and the progress messages. */
|
||||
static ModContext ctx(BootToLevelMod m, Map<String, String> o, ExeImage exe, List<String> log) throws IOException {
|
||||
TestModContext t = new TestModContext(GameData.cd(), m, o, exe);
|
||||
Map<String, String> v = new java.util.LinkedHashMap<>(t.optionValues());
|
||||
v.put("start", "");
|
||||
v.put("start", o.getOrDefault("start", ""));
|
||||
return new ModContext() {
|
||||
public Path originalCd() {
|
||||
return t.originalCd();
|
||||
@@ -55,10 +60,13 @@ class BootToLevelModTest {
|
||||
}
|
||||
|
||||
public void write(String p, byte[] d) throws IOException {
|
||||
log.add("write " + p);
|
||||
t.write(p, d);
|
||||
}
|
||||
|
||||
public void progress(double f, String msg) {}
|
||||
public void progress(double f, String msg) {
|
||||
log.add(msg);
|
||||
}
|
||||
|
||||
public ExeImage exe() throws IOException {
|
||||
return t.exe();
|
||||
@@ -92,6 +100,34 @@ class BootToLevelModTest {
|
||||
assertEquals(0x68, exe.readU8(IntroSkipMod.LOGOS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startOfAnotherLevelIsIgnored() throws Exception {
|
||||
String stale = BootToLevelMod.formatStart(3, 1, 0, -999, 0, Double.NaN); // no floor anywhere here
|
||||
assertEquals("0.000, -999.000, 0.000 @ 3/1", stale);
|
||||
BootToLevelMod m = new BootToLevelMod();
|
||||
ExeImage exe = GameData.exe();
|
||||
exe.setOwner(BootToLevelMod.ID);
|
||||
List<String> log = new java.util.ArrayList<>();
|
||||
m.apply(ctx(m, Map.of("world", "1", "chapter", "2", "start", stale), exe, log));
|
||||
// used to fail: "no collision floor at or below it"
|
||||
assertEquals(List.of("Player start ignored: it was set for world 3 chapter 1"), log);
|
||||
// in its own level it is still used (and checked)
|
||||
ExeImage exe2 = GameData.exe();
|
||||
exe2.setOwner(BootToLevelMod.ID);
|
||||
IOException e = assertThrows(
|
||||
IOException.class, () -> m.apply(ctx(m, Map.of("world", "3", "chapter", "1", "start", stale), exe2)));
|
||||
assertTrue(e.getMessage().startsWith("Player start"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesStartLevel() throws IOException {
|
||||
assertNull(BootToLevelMod.startLevel("1, 2, 3"));
|
||||
assertArrayEquals(new int[] {9, 12}, BootToLevelMod.startLevel("1, 2, 3, 90 @ 9/12"));
|
||||
assertArrayEquals(new double[] {1, 2, 3, 90}, BootToLevelMod.parseStart("1, 2, 3, 90 @ 9/12"));
|
||||
assertThrows(IOException.class, () -> BootToLevelMod.startLevel("1, 2, 3 @ 9"));
|
||||
assertThrows(IOException.class, () -> BootToLevelMod.parseStart("@ 9/1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesStart() throws IOException {
|
||||
assertNull(BootToLevelMod.parseStart(" "));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eliasmueller.llamamod.platform;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AppPathsTest {
|
||||
@Test
|
||||
void rootIsNormalized() {
|
||||
AppPaths p = new AppPaths(Path.of("/data/tmp/agent/../game-data"));
|
||||
assertEquals(Path.of("/data/tmp/game-data"), p.root());
|
||||
assertEquals(Path.of("/data/tmp/game-data/original/hd"), p.originalHd());
|
||||
}
|
||||
}
|
||||
@@ -127,4 +127,57 @@ class ConfigTest {
|
||||
assertEquals(49, r.screenW);
|
||||
assertEquals(49, r.screenH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveReportsWhetherItPulledInChangesOfAnotherWindow() throws Exception {
|
||||
AppPaths p = new AppPaths(tmp);
|
||||
Config c = new Config();
|
||||
c.layout = Config.LAYOUT;
|
||||
c.save(p.config());
|
||||
Config a = Config.load(p.config());
|
||||
Config b = Config.load(p.config());
|
||||
a.enabledMods = new java.util.ArrayList<>(java.util.List.of("fog"));
|
||||
assertFalse(a.save(p.config()), "nothing else changed");
|
||||
b.screenW = 800;
|
||||
assertTrue(b.save(p.config()), "b took over a's mod selection");
|
||||
assertEquals(java.util.List.of("fog"), b.enabledMods);
|
||||
b.screenH = 600;
|
||||
assertFalse(b.save(p.config()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTakesOverOtherWindowsChangesAndKeepsUnsavedOnes() throws Exception {
|
||||
AppPaths p = new AppPaths(tmp);
|
||||
Config c = new Config();
|
||||
c.layout = Config.LAYOUT;
|
||||
c.modOptions.put("fog", new java.util.TreeMap<>(java.util.Map.of("strength", "2")));
|
||||
c.save(p.config());
|
||||
Config window = Config.load(p.config());
|
||||
assertFalse(window.refresh(p.config()), "file unchanged");
|
||||
|
||||
Config other = Config.load(p.config());
|
||||
other.enabledMods = new java.util.ArrayList<>(java.util.List.of("fog"));
|
||||
other.modOptions.get("fog").put("strength", "5");
|
||||
other.save(p.config());
|
||||
window.theme = "dark"; // unsaved change of this window
|
||||
|
||||
assertTrue(window.refresh(p.config()));
|
||||
assertEquals(java.util.List.of("fog"), window.enabledMods);
|
||||
assertEquals("5", window.modOptions.get("fog").get("strength"));
|
||||
assertEquals("dark", window.theme);
|
||||
assertFalse(window.refresh(p.config()), "already up to date");
|
||||
|
||||
// the unsaved change still counts as this window's change at the next save
|
||||
assertFalse(window.save(p.config()));
|
||||
assertEquals("dark", Config.load(p.config()).theme);
|
||||
assertEquals("5", Config.load(p.config()).modOptions.get("fog").get("strength"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshIgnoresUnknownFieldsOfANewerVersion() throws Exception {
|
||||
AppPaths p = new AppPaths(tmp);
|
||||
Files.writeString(p.config(), "{\"layout\": 1, \"futureField\": 7}");
|
||||
Config c = Config.load(p.config());
|
||||
assertFalse(c.refresh(p.config()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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 com.eliasmueller.llamamod.mods.ModManager.Recovery;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.image.BufferedImage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecoveryNoticeTest {
|
||||
@Test
|
||||
void repairsAreShownAndBusyIsRetriedUntilTheLockIsFree() {
|
||||
RecoveryNotice n = new RecoveryNotice();
|
||||
assertNull(n.handle(Recovery.NONE));
|
||||
assertFalse(n.needsRetry());
|
||||
assertNull(n.handle(Recovery.BUSY));
|
||||
assertTrue(n.needsRetry());
|
||||
assertEquals("recover.finished", n.handle(Recovery.FINISHED));
|
||||
assertFalse(n.needsRetry());
|
||||
n.handle(Recovery.BUSY);
|
||||
assertNull(n.handle(Recovery.NONE), "the other window rebuilt cd-active itself");
|
||||
assertFalse(n.needsRetry());
|
||||
assertEquals("recover.restored", n.handle(Recovery.RESTORED));
|
||||
assertNull(n.handle(Recovery.FAILED));
|
||||
assertFalse(n.needsRetry());
|
||||
for (Recovery r : Recovery.values()) {
|
||||
String key = n.handle(r);
|
||||
assertEquals(r.repaired(), key != null);
|
||||
if (key != null) {
|
||||
assertFalse(Strings.t(key).startsWith("!"), key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerSubtitleIsCutInTheMiddle() {
|
||||
FontMetrics fm = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)
|
||||
.getGraphics()
|
||||
.getFontMetrics(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||
String s = "Version 0.2.0 · /home/someone/a/very/long/path/to/the/llamamod/data";
|
||||
assertEquals(s, HeaderPanel.elide(s, fm, 2000));
|
||||
String cut = HeaderPanel.elide(s, fm, 200);
|
||||
assertTrue(fm.stringWidth(cut) <= 200);
|
||||
assertTrue(cut.contains("…"));
|
||||
assertTrue(cut.startsWith("Ver") && cut.endsWith("data"), cut);
|
||||
assertEquals("…", HeaderPanel.elide(s, fm, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void longPathsMayWrapAfterSeparators() {
|
||||
assertEquals("/\u200Ba/\u200Bb<c", SettingsPanel.breakablePath("/a/b<c"));
|
||||
assertEquals("C:\\\u200BUsers\\\u200Bx", SettingsPanel.breakablePath("C:\\Users\\x"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user