launcher: wizard bar follows the copied megabytes, saves table shows 4 rows at the minimum size, mods tab keeps focus and caret when it is rebuilt, take-over notice expires; geom_export previews in game space; apptest checks every menu step with longer holds, loads slot 2, retries an early crash once and records load

This commit is contained in:
MineTec committed 2026-11-07 14:00:22 +01:00
1 parent d030c9c6c7
commit d6f84be125
19 files changed
+650 -67

No files matched your search

+4 -1
View File
@@ -210,7 +210,10 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the
`geom_export`/`geom_import` = the old raw export space (z < 0). `geom_export`/`geom_import` = the old raw export space (z < 0).
* `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one * `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
(textured software renderer). (textured software renderer, right-handed camera, always in game space – the same way round as in the
game, checked against an in-game shot of t1l1m001: `img/geom_preview_handedness.jpg`; before 2026-11-07
the previews rendered export space and were mirrored). `tools/vorschau/render_level.py` (Blender) reads
file space and is not mirrored either (checked on the test world, 2026-11-07).
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]` * `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]`
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads. * `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision). * `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+32 -4
View File
@@ -76,8 +76,14 @@ 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 `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 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 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 Settings tabs (status "Took over changes made in another LlamaMod window.", transient: after 6 s the
next click cannot undo another window's selection. previous status comes back, `ui/StatusLine.flash`), so a stale window's next click cannot undo another
window's selection. The Mods tab rebuild keeps keyboard focus, caret and selection of the option the user
is typing in (`ui/FocusKeeper`: inputs are tagged `modId/optionKey`; FlatLaf's select-all on focus gain is
suppressed for the restored field, it would eat the next key).
Short windows: header 150 px from 860 px window height, 104 px below, 92 px below 700 px. The Saves tab
shows the table legend ("Coins: …") in the left column, so at the 820×640 minimum the chapter table
still shows 4 rows (7 at 720 px; `img/app/saves-640.jpg`).
Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left 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`, 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 `img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the
@@ -92,6 +98,9 @@ looking for `…/Hd/groove.exe` next to `…/Cd/wads/`. After copying, `platform
same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level
WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected. WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected.
The ISO also contains three `(null)king*.ksv` leftovers; they are not copied. The ISO also contains three `(null)king*.ksv` leftovers; they are not copied.
Wizard progress: the bar follows the copied bytes exactly (`Installer.COPY_SHARE` of the install progress
is the copy), so "Copying 553 of 568 MB" stands next to 97 %; the short steps after it (check, game folder,
re-applying mods) show a busy bar without a percentage (`img/app/wizard-copy-mb-fixed.jpg`).
## Registry, CD path and the exe patch ## Registry, CD path and the exe patch
@@ -356,14 +365,14 @@ and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on f
T=tools/apptest.py T=tools/apptest.py
$T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \ $T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \
--seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep] --seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep]
# setup + apply + unlock slot 3 at world/chapter (cursor) + start + tolevel + keys + stop # setup + apply + unlock slots 2 and 3 at world/chapter (cursor) + start + tolevel (slot 2) + keys + stop
# exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg, # exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg,
# zz_failure.jpg, game.log, apply.log # zz_failure.jpg, game.log, apply.log
$T setup NAME # install session (fast after the template exists) $T setup NAME # install session (fast after the template exists)
$T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original $T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original
$T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start) $T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start)
$T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio $T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio
$T tolevel NAME --world 1 # menu "Spiel starten" -> slot 3 -> map -> Enter -> skip fly-by $T tolevel NAME --world 1 [--slot 3] # menu "Spiel starten" -> slot (default 3) -> map -> Enter -> skip fly-by
$T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s $T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s
$T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG $T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG
$T ocr NAME [WxH+X+Y] # German tesseract OCR $T ocr NAME [WxH+X+Y] # German tesseract OCR
@@ -393,5 +402,24 @@ s.key("Up", 1.5); s.shot("a.png"); print(s.crashed()); s.stop()`.
Controls are camera-relative (see testharness.md for movement measurements). Controls are camera-relative (see testharness.md for movement measurements).
* Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start. * Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start.
`tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture. `tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture.
* Flakiness under load: with several agents' games running (load average 30 on 6 cores) the game
draws only a few frames per second and the slot screen ignores presses shorter than a frame
(seen 2026-11-07 at load ~30: the save slot carousel ignored Left/Right – 0.3 to 4 s holds – for
minutes while Return/Escape worked; root cause not found). `run` therefore loads **slot 2**, where
the carousel opens (no turning; slot 3 gets the same unlock for mods whose save-slot option is 3);
3 + 3 runs passed after that change. `tolevel` checks every menu step and repeats it with longer
holds (Return on menus 0.6 s first – 0.2 s presses were often missed under load, reported by the
test-world agent too): Return on the main menu until the slot screen shows (0.8/1.0/1.2 s), Right one step at a
time until OCR reads the wanted slot (the carousel wraps 3 → 1; a step that did not move is
repeated with 0.6/1.0/1.5 s; gives up after 240 s), Return on the slot until the slot screen is
gone, Return on the map again if the picture did not change after 8 s. Before opening the slot it
checks the slot text: an empty slot (would start a new game in the village) or another world's
name (`SLOT_WORLD_NAMES`, verified: 1 "dorf", 9 "testlevel") is an error instead of a wrong level.
`run` retries the start once when the game crashed before the main menu (Wine page fault at
start-up) or the slot screen never came: report.json `"retries": [{attempt, phase, error}]`,
`retry1_failure.jpg`/`retry1_game.log` in the out dir, result line "OK (after a retry: …)";
`--no-retry` turns it off. report.json also records `load_avg`/`cpus`. Your own key sequences
(`--seq`) are not retried: hold keys ≥0.5 s for menus when the machine is busy.
Offline tests of the retry/slot logic: `python3 -m unittest tools/test_apptest.py`.
* Session state (PIDs, display): `<root>/<name>/harness/state.json`; logs `xvfb.log`, `pulse.log` * Session state (PIDs, display): `<root>/<name>/harness/state.json`; logs `xvfb.log`, `pulse.log`
there, game output in `data/logs/game.log`. Delete `<root>/<name>` to start from scratch. there, game output in `data/logs/game.log`. Delete `<root>/<name>` to start from scratch.
@@ -53,7 +53,7 @@ public final class Installer {
List<GameSource.SrcFile> cd = src.cdFiles(); List<GameSource.SrcFile> cd = src.cdFiles();
long total = totalSize(hd) + totalSize(cd); long total = totalSize(hd) + totalSize(cd);
long[] done = {0}; long[] done = {0};
Progress copyP = p.sub(0, 0.85); Progress copyP = p.sub(0, COPY_SHARE);
// files already copied are only trusted when an interrupted install of the same source // files already copied are only trusted when an interrupted install of the same source
// resumes; a new setup (other or repaired image) copies everything again // resumes; a new setup (other or repaired image) copies everything again
Path marker = paths.root().resolve("original").resolve(".install-source"); Path marker = paths.root().resolve("original").resolve(".install-source");
@@ -152,6 +152,9 @@ public final class Installer {
} }
} }
/** Share of {@link #install}'s progress that is the file copy (byte-proportional); the rest is quick steps. */
public static final double COPY_SHARE = 0.85;
/** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */ /** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */
static String copyMessage(long done, long total, String file) { 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); return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file);
@@ -0,0 +1,98 @@
package com.eliasmueller.llamamod.ui;
import java.awt.Component;
import java.awt.Container;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import javax.swing.JComponent;
import javax.swing.JSpinner;
import javax.swing.SwingUtilities;
import javax.swing.text.JTextComponent;
/**
* Keeps keyboard focus (and caret/selection) across a rebuild that replaces components: inputs are tagged with a
* stable key, the focused one is remembered before the rebuild and its replacement focused afterwards.
*/
final class FocusKeeper {
private static final String KEY = "llamamod.focusKey";
private static final String SELECT_ALL_POLICY = "JTextField.selectAllOnFocusPolicy";
/** What had the focus: the tag of the input and, for text, caret and selection. */
record Snapshot(String key, int caret, int selStart, int selEnd) {}
private FocusKeeper() {}
static <T extends JComponent> T tag(T c, String key) {
c.putClientProperty(KEY, key);
return c;
}
/** The tagged input that contains {@code focusOwner} (e.g. a spinner's text field), or null. */
static Snapshot capture(Component focusOwner, Container root) {
for (Component c = focusOwner; c != null && c != root; c = c.getParent()) {
if (c instanceof JComponent j && j.getClientProperty(KEY) instanceof String key) {
if (focusOwner instanceof JTextComponent t) {
return new Snapshot(key, t.getCaretPosition(), t.getSelectionStart(), t.getSelectionEnd());
}
return new Snapshot(key, -1, -1, -1);
}
}
return null;
}
/** Finds the replacement of the remembered input under {@code root}, restores caret/selection; null if gone. */
static Component restore(Snapshot s, Container root) {
if (s == null) {
return null;
}
JComponent c = find(root, s.key());
if (c == null) {
return null;
}
Component target =
c instanceof JSpinner sp && sp.getEditor() instanceof JSpinner.DefaultEditor ed ? ed.getTextField() : c;
if (target instanceof JTextComponent t && s.caret() >= 0) {
int len = t.getDocument().getLength();
int a = Math.min(s.selStart(), len);
int b = Math.min(s.selEnd(), len);
if (a != b) {
// keep the direction: the caret is where the user left it
t.setCaretPosition(s.caret() == a ? b : a);
t.moveCaretPosition(Math.min(s.caret(), len));
} else {
t.setCaretPosition(Math.min(s.caret(), len));
}
}
return target;
}
/** Focuses the restored input without FlatLaf's select-all on focus gain (it would eat the next key). */
static void focus(Component c) {
if (c instanceof JTextComponent t) {
t.putClientProperty(SELECT_ALL_POLICY, "never");
t.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
t.removeFocusListener(this);
SwingUtilities.invokeLater(() -> t.putClientProperty(SELECT_ALL_POLICY, null));
}
});
}
c.requestFocusInWindow();
}
private static JComponent find(Container root, String key) {
for (Component c : root.getComponents()) {
if (c instanceof JComponent j && key.equals(j.getClientProperty(KEY))) {
return j;
}
if (c instanceof Container sub) {
JComponent f = find(sub, key);
if (f != null) {
return f;
}
}
}
return null;
}
}
@@ -48,6 +48,7 @@ final class MainFrame extends JFrame {
private final HeaderPanel header = new HeaderPanel(); private final HeaderPanel header = new HeaderPanel();
private final JButton playButton = new JButton(Strings.t("play")); private final JButton playButton = new JButton(Strings.t("play"));
private final JLabel statusLabel = new JLabel(" "); private final JLabel statusLabel = new JLabel(" ");
private final StatusLine statusLine = new StatusLine(statusLabel, StatusLine.TRANSIENT_MS);
private final JProgressBar progress = new JProgressBar(0, 1000); private final JProgressBar progress = new JProgressBar(0, 1000);
private final JButton cancelButton = new JButton(Strings.t("cancel")); private final JButton cancelButton = new JButton(Strings.t("cancel"));
private final JTabbedPane tabs = new JTabbedPane(); private final JTabbedPane tabs = new JTabbedPane();
@@ -124,15 +125,19 @@ final class MainFrame extends JFrame {
return l; return l;
} }
/** Short windows (laptops, 720p) get a lower header so the mod list keeps its room. */ /** Short windows (laptops, 720p) get a lower header so the mod list and the save table keep their room. */
private void fitHeader() { private void fitHeader() {
int h = getHeight() < 860 ? 104 : 150; int h = headerHeight(getHeight());
if (header.getPreferredSize().height != h) { if (header.getPreferredSize().height != h) {
header.setPreferredSize(new Dimension(900, h)); header.setPreferredSize(new Dimension(900, h));
header.revalidate(); header.revalidate();
} }
} }
static int headerHeight(int windowHeight) {
return windowHeight < 700 ? 92 : windowHeight < 860 ? 104 : 150;
}
private void buildUi() { private void buildUi() {
JPanel root = new JPanel(new BorderLayout()); JPanel root = new JPanel(new BorderLayout());
header.setLayout(new BorderLayout()); header.setLayout(new BorderLayout());
@@ -284,7 +289,7 @@ final class MainFrame extends JFrame {
} }
modsPanel.reload(); modsPanel.reload();
settingsPanel.reload(); settingsPanel.reload();
setStatus(Strings.t("status.configMerged")); setTransientStatus(Strings.t("status.configMerged"));
} }
void onBusy(Consumer<Boolean> l) { void onBusy(Consumer<Boolean> l) {
@@ -300,7 +305,11 @@ final class MainFrame extends JFrame {
} }
void setStatus(String s) { void setStatus(String s) {
statusLabel.setText(s); statusLine.set(s);
}
void setTransientStatus(String s) {
statusLine.flash(s);
} }
private void updateGameRunning() { private void updateGameRunning() {
@@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.util.SwingListeners; import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor; import java.awt.Cursor;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.FlowLayout; import java.awt.FlowLayout;
@@ -17,6 +18,7 @@ import java.awt.GridBagConstraints;
import java.awt.GridBagLayout; import java.awt.GridBagLayout;
import java.awt.GridLayout; import java.awt.GridLayout;
import java.awt.Insets; import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Point; import java.awt.Point;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
@@ -116,6 +118,9 @@ final class ModsPanel extends JPanel {
// rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view // rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view
Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition(); Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition();
firstReload = false; firstReload = false;
// e.g. a config merge while the user types in an option: the new field gets the focus and caret
FocusKeeper.Snapshot focus = FocusKeeper.capture(
KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(), list);
list.removeAll(); list.removeAll();
boxes.clear(); boxes.clear();
removeButtons.clear(); removeButtons.clear();
@@ -148,6 +153,10 @@ final class ModsPanel extends JPanel {
updateState(); updateState();
list.revalidate(); list.revalidate();
list.repaint(); list.repaint();
Component refocus = FocusKeeper.restore(focus, list);
if (refocus != null) {
FocusKeeper.focus(refocus);
}
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
var viewport = scroll.getViewport(); var viewport = scroll.getViewport();
viewport.validate(); viewport.validate();
@@ -248,7 +257,7 @@ final class ModsPanel extends JPanel {
outer.add(c, BorderLayout.CENTER); outer.add(c, BorderLayout.CENTER);
JCheckBox cb = new JCheckBox(); JCheckBox cb = new JCheckBox();
cb.setSelected(frame.state.cfg.enabledMods.contains(m.id())); cb.setSelected(frame.state.cfg.enabledMods.contains(m.id()));
boxes.put(m.id(), cb); boxes.put(m.id(), FocusKeeper.tag(cb, m.id()));
GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST); GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST);
g.gridheight = 2; g.gridheight = 2;
g.insets = new Insets(0, 0, 0, 10); g.insets = new Insets(0, 0, 0, 10);
@@ -451,7 +460,7 @@ final class ModsPanel extends JPanel {
int row = 0; int row = 0;
for (ModOption o : m.options()) { for (ModOption o : m.options()) {
String v = values.get(o.key()); String v = values.get(o.key());
JComponent input = optionInput(m, o, v); JComponent input = optionInput(m, o, v, m.id() + "/" + o.key());
String tip = tip(o); String tip = tip(o);
GridBagConstraints g = new GridBagConstraints(); GridBagConstraints g = new GridBagConstraints();
g.gridy = row; g.gridy = row;
@@ -513,12 +522,12 @@ final class ModsPanel extends JPanel {
return t; return t;
} }
private JComponent optionInput(Mod m, ModOption o, String value) { private JComponent optionInput(Mod m, ModOption o, String value, String key) {
switch (o.type()) { switch (o.type()) {
case BOOL -> { case BOOL -> {
JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value)); JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value));
b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b)); b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b));
optionInputs.add(b); optionInputs.add(FocusKeeper.tag(b, key));
return b; return b;
} }
case INT -> { case INT -> {
@@ -527,7 +536,7 @@ final class ModsPanel extends JPanel {
Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1))); Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1)));
s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s)); s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s));
s.setPreferredSize(new Dimension(110, s.getPreferredSize().height)); s.setPreferredSize(new Dimension(110, s.getPreferredSize().height));
optionInputs.add(s); optionInputs.add(FocusKeeper.tag(s, key));
return s; return s;
} }
case CHOICE -> { case CHOICE -> {
@@ -540,7 +549,7 @@ final class ModsPanel extends JPanel {
} }
c.addActionListener(e -> c.addActionListener(e ->
setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c)); setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c));
optionInputs.add(c); optionInputs.add(FocusKeeper.tag(c, key));
return c; return c;
} }
case MULTI -> { case MULTI -> {
@@ -563,16 +572,16 @@ final class ModsPanel extends JPanel {
} }
setOption(m, o, o.multiValue(chosen), p); setOption(m, o, o.multiValue(chosen), p);
}); });
optionInputs.add(b); optionInputs.add(FocusKeeper.tag(b, key + "#" + c.value()));
p.add(b); p.add(b);
} }
return p; return p;
} }
case SEED -> { case SEED -> {
JTextField t = textInput(m, o, value, 12); JTextField t = textInput(m, o, value, 12, key);
JButton random = new JButton(Strings.t("mods.option.random")); JButton random = new JButton(Strings.t("mods.option.random"));
random.addActionListener(e -> t.setText(ModOptions.randomSeed())); random.addActionListener(e -> t.setText(ModOptions.randomSeed()));
optionInputs.add(random); optionInputs.add(FocusKeeper.tag(random, key + "#random"));
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
p.setOpaque(false); p.setOpaque(false);
p.add(t); p.add(t);
@@ -581,15 +590,15 @@ final class ModsPanel extends JPanel {
return p; return p;
} }
default -> { default -> {
return textInput(m, o, value, 20); return textInput(m, o, value, 20, key);
} }
} }
} }
private JTextField textInput(Mod m, ModOption o, String value, int columns) { private JTextField textInput(Mod m, ModOption o, String value, int columns, String key) {
JTextField t = new JTextField(value, columns); JTextField t = new JTextField(value, columns);
SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t)); SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t));
optionInputs.add(t); optionInputs.add(FocusKeeper.tag(t, key));
return t; return t;
} }
@@ -78,9 +78,14 @@ final class SavesPanel extends JPanel {
JPanel left = new JPanel(new BorderLayout()); JPanel left = new JPanel(new BorderLayout());
JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f); JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f);
hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0)); hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0));
// the table's legend lives here, where there is room: at the minimum window height every
// line under the table would cost a table row
JLabel coinsHint = App.hintLabel(Strings.t("saves.coinsHint"), 200, 11.5f);
coinsHint.setBorder(BorderFactory.createEmptyBorder(10, 2, 0, 0));
JPanel leftTop = new JPanel(new BorderLayout()); JPanel leftTop = new JPanel(new BorderLayout());
leftTop.add(slots, BorderLayout.NORTH); leftTop.add(slots, BorderLayout.NORTH);
leftTop.add(hint, BorderLayout.CENTER); leftTop.add(hint, BorderLayout.CENTER);
leftTop.add(coinsHint, BorderLayout.SOUTH);
left.add(leftTop, BorderLayout.NORTH); left.add(leftTop, BorderLayout.NORTH);
add(left, BorderLayout.WEST); add(left, BorderLayout.WEST);
@@ -94,7 +99,7 @@ final class SavesPanel extends JPanel {
slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f)); slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f));
ed.add(slotTitle, g); ed.add(slotTitle, g);
g.gridy++; g.gridy++;
g.insets = new Insets(2, 0, 10, 0); g.insets = new Insets(2, 0, 4, 0);
ed.add(slotInfo, g); ed.add(slotInfo, g);
for (int w = 1; w <= Worlds.COUNT; w++) { for (int w = 1; w <= Worlds.COUNT; w++) {
@@ -143,10 +148,6 @@ final class SavesPanel extends JPanel {
JScrollPane sp = new JScrollPane(table); JScrollPane sp = new JScrollPane(table);
sp.setPreferredSize(new Dimension(420, 180)); sp.setPreferredSize(new Dimension(420, 180));
ed.add(sp, g); ed.add(sp, g);
g.gridy++;
g.weighty = 0;
g.fill = GridBagConstraints.HORIZONTAL;
ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g);
add(ed, BorderLayout.CENTER); add(ed, BorderLayout.CENTER);
JPanel buttons = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new BorderLayout());
@@ -313,6 +313,24 @@ final class SetupWizard extends JDialog {
dispose(); dispose();
} }
/**
* The bar follows the copied megabytes exactly (so it matches the "Copying x of y MB" text); the short steps
* after the copy show a busy bar instead of a percentage.
*/
private void showProgress(double installFraction, String message) {
double copy = barFraction(installFraction);
boolean copying = copy < 1;
bar.setIndeterminate(!copying);
bar.setStringPainted(copying);
bar.setValue((int) Math.round(copy * 1000));
progressText.setText(message);
}
/** Copy share of the installer progress mapped onto 0..1. */
static double barFraction(double installFraction) {
return Math.max(0, Math.min(1, installFraction / Installer.COPY_SHARE));
}
private void startInstall() { private void startInstall() {
if (!checkSource()) { if (!checkSource()) {
frame.error(new IllegalArgumentException(Strings.t("wizard.noSource"))); frame.error(new IllegalArgumentException(Strings.t("wizard.noSource")));
@@ -331,10 +349,7 @@ final class SetupWizard extends JDialog {
Progress p = new Progress() { Progress p = new Progress() {
@Override @Override
public void update(double f, String m) { public void update(double f, String m) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> showProgress(f, m));
bar.setValue((int) (f * 1000));
progressText.setText(m);
});
} }
@Override @Override
@@ -346,10 +361,10 @@ final class SetupWizard extends JDialog {
@Override @Override
protected Object doInBackground() throws Exception { protected Object doInBackground() throws Exception {
try (GameSource gs = GameSource.open(src)) { try (GameSource gs = GameSource.open(src)) {
new Installer(state.paths).install(gs, state.cfg, p.sub(0, 0.95)); new Installer(state.paths).install(gs, state.cfg, p);
} }
// re-setup keeps the active mods // re-setup keeps the active mods
state.modManager.apply(state.activeMods(), p.sub(0.95, 1)); state.modManager.apply(state.activeMods(), (f, m) -> p.update(1, m));
return state.saves.migrateNullSaves(); return state.saves.migrateNullSaves();
} }
@@ -0,0 +1,36 @@
package com.eliasmueller.llamamod.ui;
import javax.swing.JLabel;
import javax.swing.Timer;
/** The status bar text: lasting messages, and transient ones that give way to the lasting one after a while. */
final class StatusLine {
static final int TRANSIENT_MS = 6000;
private final JLabel label;
private final Timer expire;
private String resting;
StatusLine(JLabel label, int transientMs) {
this.label = label;
this.resting = label.getText();
expire = new Timer(transientMs, e -> label.setText(resting));
expire.setRepeats(false);
}
void set(String s) {
expire.stop();
resting = s;
label.setText(s);
}
/** Shown for a few seconds, then the last lasting message comes back (unless something newer replaced it). */
void flash(String s) {
label.setText(s);
expire.restart();
}
boolean showingTransient() {
return expire.isRunning();
}
}
@@ -0,0 +1,83 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import org.junit.jupiter.api.Test;
class FocusKeeperTest {
private static JPanel form(String text) {
JPanel root = new JPanel();
JPanel card = new JPanel();
card.add(FocusKeeper.tag(new JTextField(text, 10), "mod/name"));
card.add(FocusKeeper.tag(new JSpinner(new SpinnerNumberModel(5, 0, 9, 1)), "mod/count"));
root.add(card);
return root;
}
private static JTextField field(JPanel root) {
return (JTextField) ((JPanel) root.getComponent(0)).getComponent(0);
}
@Test
void caretMovesToTheRebuiltField() {
JPanel before = form("llama");
JTextField old = field(before);
old.setCaretPosition(3);
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
assertNotNull(s);
assertEquals("mod/name", s.key());
JPanel after = form("llama");
assertSame(field(after), FocusKeeper.restore(s, after));
assertEquals(3, field(after).getCaretPosition());
}
@Test
void selectionAndShorterTextAreHandled() {
JPanel before = form("llamas");
JTextField old = field(before);
old.setCaretPosition(1);
old.moveCaretPosition(5);
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
JPanel after = form("llamas");
FocusKeeper.restore(s, after);
assertEquals(1, field(after).getSelectionStart());
assertEquals(5, field(after).getSelectionEnd());
assertEquals(5, field(after).getCaretPosition());
JPanel shorter = form("ll");
FocusKeeper.restore(s, shorter);
assertEquals(2, field(shorter).getCaretPosition());
}
@Test
void spinnerEditorMapsToTheSpinner() {
JPanel before = form("x");
JSpinner sp = (JSpinner) ((JPanel) before.getComponent(0)).getComponent(1);
JTextField editor = ((JSpinner.DefaultEditor) sp.getEditor()).getTextField();
FocusKeeper.Snapshot s = FocusKeeper.capture(editor, before);
assertEquals("mod/count", s.key());
JPanel after = form("x");
JSpinner sp2 = (JSpinner) ((JPanel) after.getComponent(0)).getComponent(1);
assertSame(((JSpinner.DefaultEditor) sp2.getEditor()).getTextField(), FocusKeeper.restore(s, after));
}
@Test
void untaggedOrMissingGivesNull() {
JPanel root = new JPanel();
JTextField plain = new JTextField();
root.add(plain);
assertNull(FocusKeeper.capture(plain, root));
assertNull(FocusKeeper.capture(null, root));
assertNull(FocusKeeper.restore(new FocusKeeper.Snapshot("gone", 0, 0, 0), root));
}
}
@@ -0,0 +1,16 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class MainFrameLayoutTest {
@Test
void headerShrinksForShortWindows() {
assertEquals(150, MainFrame.headerHeight(900));
assertEquals(104, MainFrame.headerHeight(760));
assertEquals(104, MainFrame.headerHeight(720));
// the 640 px minimum: the Saves table keeps four rows
assertEquals(92, MainFrame.headerHeight(640));
}
}
@@ -0,0 +1,18 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.eliasmueller.llamamod.platform.Installer;
import org.junit.jupiter.api.Test;
class SetupWizardProgressTest {
@Test
void barFollowsTheCopiedMegabytes() {
// 553 of 568 MB copied: the bar must say 97 %, not the 78 % of the whole setup
double copied = 553.0 / 568.0;
assertEquals(copied, SetupWizard.barFraction(copied * Installer.COPY_SHARE), 1e-9);
assertEquals(0, SetupWizard.barFraction(0));
assertEquals(1, SetupWizard.barFraction(Installer.COPY_SHARE));
assertEquals(1, SetupWizard.barFraction(0.9)); // checking/creating: full (shown as busy)
}
}
@@ -0,0 +1,45 @@
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.assertTrue;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;
import org.junit.jupiter.api.Test;
class StatusLineTest {
private static void onEdt(Runnable r) throws Exception {
SwingUtilities.invokeAndWait(r);
}
@Test
void transientMessageGivesWayToTheLastingOne() throws Exception {
JLabel l = new JLabel(" ");
StatusLine s = new StatusLine(l, 150);
onEdt(() -> {
s.set("Ready.");
s.flash("Took over changes made in another LlamaMod window.");
});
assertEquals("Took over changes made in another LlamaMod window.", l.getText());
assertTrue(s.showingTransient());
Thread.sleep(600);
onEdt(() -> {});
assertEquals("Ready.", l.getText());
assertFalse(s.showingTransient());
}
@Test
void newerLastingMessageIsNotOverwritten() throws Exception {
JLabel l = new JLabel(" ");
StatusLine s = new StatusLine(l, 150);
onEdt(() -> {
s.set("Ready.");
s.flash("merged");
s.set("Applying mods");
});
Thread.sleep(600);
onEdt(() -> {});
assertEquals("Applying mods", l.getText());
}
}
+156 -32
View File
@@ -14,14 +14,17 @@ Commands (NAME first; --display only for 'start'/'run', remembered afterwards):
apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI
apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there
apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch) apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch)
apptest.py tolevel NAME [--world W] main menu -> slot 3 -> map -> level -> gameplay apptest.py tolevel NAME [--world W] [--slot S] main menu -> slot S (3) -> map -> level -> gameplay
apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2) apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2)
apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG) apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG)
apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop) apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop)
apptest.py status NAME game/window/crash/audio state (exit 3 = crashed) apptest.py status NAME game/window/crash/audio state (exit 3 = crashed)
apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME
apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep] apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep]
all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage [--no-retry] all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage.
A crash before the main menu or a missed save slot screen
is retried once (report.json "retries", out/retry1_*);
--no-retry turns that off
Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar), Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar),
--iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`. --iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`.
@@ -34,6 +37,7 @@ Safety: never uses display :0, never touches ~/.wine-lama, kills only PIDs it st
import fcntl import fcntl
import json import json
import os import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -51,6 +55,10 @@ SLOT_CROP = "1600x280+160+740" # save-slot text "Gespeichertes Spiel N /
MENU_CROP = "700x300+1150+650" # main menu items (bottom right) MENU_CROP = "700x300+1150+650" # main menu items (bottom right)
# world -> word the save slot shows for it (OCR, lower case); only the verified ones
SLOT_WORLD_NAMES = {1: "dorf", 9: "testlevel"}
class Crash(Exception): class Crash(Exception):
pass pass
@@ -517,8 +525,8 @@ class Session:
raise Timeout("timeout (%d s) waiting for %s" % (timeout, what)) raise Timeout("timeout (%d s) waiting for %s" % (timeout, what))
# ------------------------------------------------------------ navigation # ------------------------------------------------------------ navigation
def tolevel(self, world=None, skip_flyby=True): def tolevel(self, world=None, skip_flyby=True, on_menu=None, slot=3):
"""Main menu -> "Spiel starten" -> slot 3 (rightmost) -> map (cursor from --unlock ... --cursor) """Main menu -> "Spiel starten" -> save slot (default 3) -> map (cursor from --unlock ... --cursor)
-> Enter -> skip the fly-by -> gameplay. Returns timings.""" -> Enter -> skip the fly-by -> gameplay. Returns timings."""
T = {} T = {}
t0 = tx = time.time() t0 = tx = time.time()
@@ -528,10 +536,14 @@ class Session:
raise Crash(c) raise Crash(c)
if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")): if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")):
log("main menu") log("main menu")
if on_menu:
on_menu()
time.sleep(0.5) time.sleep(0.5)
self.key("Return") self.key("Return", 0.6)
break break
if fuzzy_in("gespeichertes", self.slot_text()): if fuzzy_in("gespeichertes", self.slot_text()):
if on_menu:
on_menu()
break break
if time.time() - tx > 40: if time.time() - tx > 40:
log("no menu yet -> Return (skips logo/demo)") log("no menu yet -> Return (skips logo/demo)")
@@ -541,21 +553,15 @@ class Session:
raise Timeout("main menu not reached") raise Timeout("main menu not reached")
time.sleep(1) time.sleep(1)
T["menu"] = round(time.time() - t0, 1) T["menu"] = round(time.time() - t0, 1)
self.wait_for(lambda: fuzzy_in("gespeichertes", self.slot_text()), 20, "save slot screen") self._to_slot_screen()
time.sleep(0.4) t = self._select_slot(slot)
for _ in range(2): # 3-slot slider without wrap: slot 3 is the rightmost if not self._slot_ok(t):
self.key("Right", 0.3) # an empty slot starts a new game in the village: never the level that was asked for
time.sleep(0.3) raise Timeout("save slot %d is empty (%s) - unlock it first" % (slot, t))
for i in range(3): name = SLOT_WORLD_NAMES.get(world)
time.sleep(0.6) if name and not fuzzy_in(name, t):
t = self.slot_text() raise Timeout("save slot %d holds another world (%s), expected %r" % (slot, t, name))
if fuzzy_in("kapitel", t) and not fuzzy_in("leer", t): self._open_slot()
log("slot: %s" % t.split(" | ")[0])
break
self.key("Right", 0.3)
else:
log("WARNING: slot 3 text not recognised (%s)" % t)
self.key("Return")
T["slot"] = round(time.time() - t0, 1) T["slot"] = round(time.time() - t0, 1)
if world == 9: if world == 9:
self.wait_for(lambda: self.classify() == "map", 30, "world 9 map") self.wait_for(lambda: self.classify() == "map", 30, "world 9 map")
@@ -563,10 +569,11 @@ class Session:
else: else:
self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map") self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map")
self.wait_stable(still=1.5, timeout=20) self.wait_stable(still=1.5, timeout=20)
self.key("Return") map_thumb = self.thumb()
self.key("Return", 0.6)
t_load = time.time() t_load = time.time()
T["map"] = round(t_load - t0, 1) T["map"] = round(t_load - t0, 1)
skipped, good, states = False, 0, [] skipped, good, states, again = False, 0, [], 0
while time.time() - t_load < 150: while time.time() - t_load < 150:
c = self.crashed() c = self.crashed()
if c: if c:
@@ -575,7 +582,12 @@ class Session:
if not states or states[-1] != st: if not states or states[-1] != st:
states.append(st) states.append(st)
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load)) log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
if st == "letterbox" and skip_flyby and not skipped: if "black" not in states and "letterbox" not in states and time.time() - t_load > 8 + 8 * again \
and again < 2 and self.diff(self.thumb(), map_thumb) < 6:
again += 1 # still on the map: the Return was missed
log("level did not start -> Return again")
self.key("Return", 0.6 + 0.3 * again)
elif st == "letterbox" and skip_flyby and not skipped:
time.sleep(0.5) time.sleep(0.5)
self.key("Return") self.key("Return")
skipped = True skipped = True
@@ -596,6 +608,79 @@ class Session:
log("gameplay after %.1f s, states %s" % (T["gameplay"], states)) log("gameplay after %.1f s, states %s" % (T["gameplay"], states))
return T return T
def _to_slot_screen(self, timeout=45):
"""Waits for the save slot screen; a Return the menu missed (still fading in, or a key press shorter
than one frame on a loaded machine) is repeated, held a little longer each time."""
t0 = tr = time.time()
holds = [0.8, 1.0, 1.2]
while time.time() - t0 < timeout:
c = self.crashed()
if c:
raise Crash(c)
if fuzzy_in("gespeichertes", self.slot_text()):
return
if time.time() - tr > 5 and fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6,
scale="100%")):
hold = holds.pop(0) if holds else 1.2
log("still in the main menu -> Return again (held %.1f s)" % hold)
self.key("Return", hold)
tr = time.time()
time.sleep(0.7)
raise Timeout("timeout (%d s) waiting for save slot screen" % timeout)
@staticmethod
def _slot_ok(text):
return fuzzy_in("kapitel", text) and not fuzzy_in("leer", text)
@staticmethod
def _slot_no(text):
"""Slot number from "gespeichertes spiel N" (OCR text), or None."""
m = re.search(r"spiel\s*([123])\b", text)
return int(m.group(1)) if m else None
def _select_slot(self, slot=3, timeout=240):
"""Turns the save slot carousel (it opens on slot 2) to `slot` and checks its text after every step.
The carousel wraps (3 -> 1) and ignores keys while it turns, so Right is pressed once per step and
only again when the text shows another slot. A step that did not move is repeated with a longer
hold (0.3/0.6/1.0/1.5 s). On a busy machine (load ~30, several games) the carousel ignored
Left/Right for minutes while Return/Escape worked: `run` therefore uses slot 2, which needs no
turning."""
self.wait_stable(still=0.8, timeout=6)
holds = [0.3, 0.6, 1.0, 1.5]
t0 = time.time()
t = self.slot_text()
while time.time() - t0 < timeout:
if self._slot_no(t) == slot:
log("slot: %s" % t.split(" | ")[0])
return t
if not fuzzy_in("gespeichertes", t):
raise Timeout("save slot screen lost while selecting slot %d (%s)" % (slot, t))
before = self._slot_no(t)
self.key("Right", holds[0])
t1 = time.time()
while time.time() - t1 < 8: # the carousel turns, then the new text shows
time.sleep(0.6)
t = self.slot_text()
if self._slot_no(t) not in (None, before):
break
else:
if len(holds) > 1:
holds.pop(0)
log("slot carousel did not move (slot %s) -> Right again, held %.1f s" % (before, holds[0]))
raise Timeout("save slot %d not selected after %d s (%s)" % (slot, timeout, t))
def _open_slot(self):
"""Return on the slot until the slot screen is gone (a short press can be missed under load)."""
for hold in (0.6, 0.9, 1.2):
self.key("Return", hold)
t1 = time.time()
while time.time() - t1 < 6:
time.sleep(0.7)
if not fuzzy_in("gespeichertes", self.slot_text()):
return
log("still on the save slot screen -> Return again")
raise Timeout("save slot did not open")
def status(self): def status(self):
s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(), s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(),
"window": self.find_window() if self.display and self.state.get("xvfb_pid") else None, "window": self.find_window() if self.display and self.state.get("xvfb_pid") else None,
@@ -606,18 +691,53 @@ class Session:
return s return s
def run(sess, mods, opts, level, seq, out, keep=False): def start_to_level(sess, level, out, report, retry=True):
"""start + tolevel; a crash before the main menu (Wine page faults at start-up, not caused by mods) or a
menu that was not recognised is retried once with a fresh game start. Each retry is listed in
report["retries"] and logged; the failed attempt's screenshot/log go to out/retry1_*."""
for attempt in (1, 2):
phase = "start"
try:
sess.start()
if level:
phase = "menu"
report["tolevel"] = sess.tolevel(world=level[0], slot=2,
on_menu=lambda: report.update(menu_reached=True))
return
except (Crash, Timeout) as e:
early = isinstance(e, Crash) and not report.get("menu_reached") or \
isinstance(e, Timeout) and "save slot" in str(e)
if not retry or attempt == 2 or not early:
raise
log("WARNING: %s during %s -> stopping and starting the game once more" % (e, phase))
try:
sess.shot(os.path.join(out, "retry1_failure.jpg"), small=True)
except SystemExit:
pass
gl = os.path.join(sess.data, "logs", "game.log")
if os.path.exists(gl):
shutil.copy(gl, os.path.join(out, "retry1_game.log"))
report.setdefault("retries", []).append({"attempt": attempt, "phase": phase, "error": str(e)})
report.pop("menu_reached", None)
sess.stop()
def run(sess, mods, opts, level, seq, out, keep=False, retry=True):
os.makedirs(out, exist_ok=True) os.makedirs(out, exist_ok=True)
report = {"mods": mods, "opts": opts, "level": level} # a busy machine (several agents' games) slows the game to a few frames per second: the usual cause of
# missed key presses; recorded to tell flaky runs apart
report = {"mods": mods, "opts": opts, "level": level, "load_avg": [round(x, 1) for x in os.getloadavg()],
"cpus": os.cpu_count()}
code = 0 code = 0
try: try:
sess.setup() sess.setup()
report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:] report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:]
if level: if level:
# slot 2 is where the carousel opens (no turning, see _select_slot); slot 3 gets the same
# progress for mods whose "save slot" option defaults to 3
sess.unlock(2, level[0], level[1])
sess.unlock(3, level[0], level[1]) sess.unlock(3, level[0], level[1])
sess.start() start_to_level(sess, level, out, report, retry)
if level:
report["tolevel"] = sess.tolevel(world=level[0])
sess.shot(os.path.join(out, "00_start.png")) sess.shot(os.path.join(out, "00_start.png"))
if seq: if seq:
report["shots"] = sess.keys(seq, outdir=out) report["shots"] = sess.keys(seq, outdir=out)
@@ -647,7 +767,8 @@ def run(sess, mods, opts, level, seq, out, keep=False):
json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1) json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1)
if not keep: if not keep:
sess.stop() sess.stop()
log("result: %s" % (report.get("error") or "OK")) retried = "" if not report.get("retries") else " (after a retry: %s)" % report["retries"][0]["error"]
log("result: %s%s" % (report.get("error") or "OK", retried))
return code return code
@@ -660,7 +781,8 @@ def main(argv):
i = 0 i = 0
while i < len(rest): while i < len(rest):
a = rest[i] a = rest[i]
if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--seq", "--out") and i + 1 < len(rest): if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--slot", "--seq", "--out") \
and i + 1 < len(rest):
kw[a[2:]] = rest[i + 1] kw[a[2:]] = rest[i + 1]
i += 2 i += 2
elif a == "--opt" and i + 1 < len(rest): elif a == "--opt" and i + 1 < len(rest):
@@ -685,7 +807,8 @@ def main(argv):
elif cmd == "start": elif cmd == "start":
s.start() s.start()
elif cmd == "tolevel": elif cmd == "tolevel":
print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None))) print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None,
slot=int(kw.get("slot", 3)))))
elif cmd == "keys": elif cmd == "keys":
print(s.keys(pos, outdir=kw.get("out"))) print(s.keys(pos, outdir=kw.get("out")))
elif cmd == "shot": elif cmd == "shot":
@@ -703,7 +826,8 @@ def main(argv):
print("run needs --display N and --out DIR") print("run needs --display N and --out DIR")
return 2 return 2
mods = [x for x in kw.get("mods", "").split(",") if x] mods = [x for x in kw.get("mods", "").split(",") if x]
return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos) return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos,
retry="--no-retry" not in pos)
else: else:
print(__doc__) print(__doc__)
return 2 return 2
+11 -2
View File
@@ -9,7 +9,8 @@ Writes to outdir:
page_NN.png texture pages (256x256, from TEXT) page_NN.png texture pages (256x256, from TEXT)
collision.obj (--collision) collision polygons, grouped by material collision.obj (--collision) collision polygons, grouped by material
objects.obj (--objects) static STPC object models side by side objects.obj (--objects) static STPC object models side by side
preview_top.png, preview_persp.png textured software renderings for checking preview_top.png, preview_persp.png textured software renderings for checking (game space,
the same way round as in the game)
Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as
Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes
@@ -101,6 +102,11 @@ def write_mtl(path, tris, texs, npages):
# ------------------------------------------------------------ software renderer # ------------------------------------------------------------ software renderer
def game_space_tris(tris):
"""world_tris (export space) -> game space for the right-handed preview camera."""
return [([G.to_file(p) for p in P], *rest) for P, *rest in tris]
def render(tris, pages, cam, size, path, texs): def render(tris, pages, cam, size, path, texs):
Wd, Hd = size Wd, Hd = size
eye, target, fov = cam eye, target, fov = cam
@@ -237,10 +243,13 @@ def main():
for k, im in enumerate(imgs): for k, im in enumerate(imgs):
Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png')) Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png'))
size = tuple(int(s) for s in a.size.split('x')) size = tuple(int(s) for s in a.size.split('x'))
# the renderer's camera is right-handed: game space shows the level as the game does
# (export space would come out mirrored); also with --export-space
tris = game_space_tris(tris)
P = np.array([p for t in tris for p in t[0]]) P = np.array([p for t in tris for p in t[0]])
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2 lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
ext = max(hi[0] - lo[0], hi[2] - lo[2]) ext = max(hi[0] - lo[0], hi[2] - lo[2])
# top view (almost vertical, north = -z at the top) # top view (almost vertical, game -z at the top)
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size, render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
os.path.join(a.outdir, 'preview_top.png'), texs) os.path.join(a.outdir, 'preview_top.png'), texs)
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size, render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
+86
View File
@@ -0,0 +1,86 @@
"""Offline tests of apptest's retry/slot logic (no game needed): python3 -m unittest tools/test_apptest.py"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import apptest as A # noqa: E402
class FakeSession:
def __init__(self, outcomes):
self.outcomes = list(outcomes) # per start: None ok, or exception raised by start/tolevel
self.data = tempfile.mkdtemp()
self.starts = self.stops = 0
def start(self):
self.starts += 1
o = self.outcomes.pop(0)
if isinstance(o, A.Crash) and "start" in str(o):
raise o
self._next = o
def tolevel(self, world=None, slot=3, on_menu=None):
o = self._next
if isinstance(o, A.Crash) and "level" in str(o):
on_menu()
raise o
if o:
raise o
on_menu()
return {"slot": slot}
def shot(self, path, small=False):
open(path, "w").close()
def stop(self):
self.stops += 1
class StartRetryTest(unittest.TestCase):
def setUp(self):
self.out = tempfile.mkdtemp()
def test_early_crash_is_retried_once_and_reported(self):
s, rep = FakeSession([A.Crash("start: page fault"), None]), {}
A.start_to_level(s, (1, 1), self.out, rep)
self.assertEqual(2, s.starts)
self.assertEqual([{"attempt": 1, "phase": "start", "error": "start: page fault"}], rep["retries"])
self.assertEqual({"slot": 2}, rep["tolevel"])
self.assertTrue(os.path.exists(os.path.join(self.out, "retry1_failure.jpg")))
def test_missed_slot_screen_is_retried(self):
s, rep = FakeSession([A.Timeout("timeout (45 s) waiting for save slot screen"), None]), {}
A.start_to_level(s, (1, 1), self.out, rep)
self.assertEqual("menu", rep["retries"][0]["phase"])
def test_crash_after_the_menu_is_not_retried(self):
s, rep = FakeSession([A.Crash("level: page fault"), None]), {}
with self.assertRaises(A.Crash):
A.start_to_level(s, (1, 1), self.out, rep)
self.assertEqual(1, s.starts)
self.assertNotIn("retries", rep)
def test_second_failure_and_no_retry_raise(self):
s = FakeSession([A.Crash("start: a"), A.Crash("start: b")])
with self.assertRaises(A.Crash):
A.start_to_level(s, None, self.out, {})
self.assertEqual(2, s.starts)
s = FakeSession([A.Crash("start: a"), None])
with self.assertRaises(A.Crash):
A.start_to_level(s, None, self.out, {}, retry=False)
class SlotTextTest(unittest.TestCase):
def test_slot_number_and_content(self):
S = A.Session
self.assertEqual(2, S._slot_no(". gespeichertes spiel 2 u leer | gespeichertes spiel 2 u leer"))
self.assertEqual(3, S._slot_no("gespeichertes spiel 3 dorf kapitel 1"))
self.assertIsNone(S._slot_no("eingabe neues spiel starten"))
self.assertTrue(S._slot_ok("gespeichertes spiel 2 u testlevel kapitel 1 goldmünzen"))
self.assertFalse(S._slot_ok("gespeichertes spiel 2 u leer"))
if __name__ == "__main__":
unittest.main()