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.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
(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_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).
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
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.
Settings tabs (status "Took over changes made in another LlamaMod window.", transient: after 6 s the
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
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
@@ -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
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.
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
@@ -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 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]
# 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,
# zz_failure.jpg, game.log, apply.log
$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 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 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 shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG
$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).
* 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.
* 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`
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();
long total = totalSize(hd) + totalSize(cd);
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
// resumes; a new setup (other or repaired image) copies everything again
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)". */
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);
@@ -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 JButton playButton = new JButton(Strings.t("play"));
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 JButton cancelButton = new JButton(Strings.t("cancel"));
private final JTabbedPane tabs = new JTabbedPane();
@@ -124,15 +125,19 @@ final class MainFrame extends JFrame {
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() {
int h = getHeight() < 860 ? 104 : 150;
int h = headerHeight(getHeight());
if (header.getPreferredSize().height != h) {
header.setPreferredSize(new Dimension(900, h));
header.revalidate();
}
}
static int headerHeight(int windowHeight) {
return windowHeight < 700 ? 92 : windowHeight < 860 ? 104 : 150;
}
private void buildUi() {
JPanel root = new JPanel(new BorderLayout());
header.setLayout(new BorderLayout());
@@ -284,7 +289,7 @@ final class MainFrame extends JFrame {
}
modsPanel.reload();
settingsPanel.reload();
setStatus(Strings.t("status.configMerged"));
setTransientStatus(Strings.t("status.configMerged"));
}
void onBusy(Consumer<Boolean> l) {
@@ -300,7 +305,11 @@ final class MainFrame extends JFrame {
}
void setStatus(String s) {
statusLabel.setText(s);
statusLine.set(s);
}
void setTransientStatus(String s) {
statusLine.flash(s);
}
private void updateGameRunning() {
@@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FlowLayout;
@@ -17,6 +18,7 @@ import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Point;
import java.awt.event.MouseAdapter;
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
Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition();
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();
boxes.clear();
removeButtons.clear();
@@ -148,6 +153,10 @@ final class ModsPanel extends JPanel {
updateState();
list.revalidate();
list.repaint();
Component refocus = FocusKeeper.restore(focus, list);
if (refocus != null) {
FocusKeeper.focus(refocus);
}
SwingUtilities.invokeLater(() -> {
var viewport = scroll.getViewport();
viewport.validate();
@@ -248,7 +257,7 @@ final class ModsPanel extends JPanel {
outer.add(c, BorderLayout.CENTER);
JCheckBox cb = new JCheckBox();
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);
g.gridheight = 2;
g.insets = new Insets(0, 0, 0, 10);
@@ -451,7 +460,7 @@ final class ModsPanel extends JPanel {
int row = 0;
for (ModOption o : m.options()) {
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);
GridBagConstraints g = new GridBagConstraints();
g.gridy = row;
@@ -513,12 +522,12 @@ final class ModsPanel extends JPanel {
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()) {
case BOOL -> {
JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value));
b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b));
optionInputs.add(b);
optionInputs.add(FocusKeeper.tag(b, key));
return b;
}
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)));
s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s));
s.setPreferredSize(new Dimension(110, s.getPreferredSize().height));
optionInputs.add(s);
optionInputs.add(FocusKeeper.tag(s, key));
return s;
}
case CHOICE -> {
@@ -540,7 +549,7 @@ final class ModsPanel extends JPanel {
}
c.addActionListener(e ->
setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c));
optionInputs.add(c);
optionInputs.add(FocusKeeper.tag(c, key));
return c;
}
case MULTI -> {
@@ -563,16 +572,16 @@ final class ModsPanel extends JPanel {
}
setOption(m, o, o.multiValue(chosen), p);
});
optionInputs.add(b);
optionInputs.add(FocusKeeper.tag(b, key + "#" + c.value()));
p.add(b);
}
return p;
}
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"));
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));
p.setOpaque(false);
p.add(t);
@@ -581,15 +590,15 @@ final class ModsPanel extends JPanel {
return p;
}
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);
SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t));
optionInputs.add(t);
optionInputs.add(FocusKeeper.tag(t, key));
return t;
}
@@ -78,9 +78,14 @@ final class SavesPanel extends JPanel {
JPanel left = new JPanel(new BorderLayout());
JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f);
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());
leftTop.add(slots, BorderLayout.NORTH);
leftTop.add(hint, BorderLayout.CENTER);
leftTop.add(coinsHint, BorderLayout.SOUTH);
left.add(leftTop, BorderLayout.NORTH);
add(left, BorderLayout.WEST);
@@ -94,7 +99,7 @@ final class SavesPanel extends JPanel {
slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f));
ed.add(slotTitle, g);
g.gridy++;
g.insets = new Insets(2, 0, 10, 0);
g.insets = new Insets(2, 0, 4, 0);
ed.add(slotInfo, g);
for (int w = 1; w <= Worlds.COUNT; w++) {
@@ -143,10 +148,6 @@ final class SavesPanel extends JPanel {
JScrollPane sp = new JScrollPane(table);
sp.setPreferredSize(new Dimension(420, 180));
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);
JPanel buttons = new JPanel(new BorderLayout());
@@ -313,6 +313,24 @@ final class SetupWizard extends JDialog {
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() {
if (!checkSource()) {
frame.error(new IllegalArgumentException(Strings.t("wizard.noSource")));
@@ -331,10 +349,7 @@ final class SetupWizard extends JDialog {
Progress p = new Progress() {
@Override
public void update(double f, String m) {
SwingUtilities.invokeLater(() -> {
bar.setValue((int) (f * 1000));
progressText.setText(m);
});
SwingUtilities.invokeLater(() -> showProgress(f, m));
}
@Override
@@ -346,10 +361,10 @@ final class SetupWizard extends JDialog {
@Override
protected Object doInBackground() throws Exception {
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
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();
}
@@ -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 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 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 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 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 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),
--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 json
import os
import re
import shutil
import signal
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)
# 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):
pass
@@ -517,8 +525,8 @@ class Session:
raise Timeout("timeout (%d s) waiting for %s" % (timeout, what))
# ------------------------------------------------------------ navigation
def tolevel(self, world=None, skip_flyby=True):
"""Main menu -> "Spiel starten" -> slot 3 (rightmost) -> map (cursor from --unlock ... --cursor)
def tolevel(self, world=None, skip_flyby=True, on_menu=None, slot=3):
"""Main menu -> "Spiel starten" -> save slot (default 3) -> map (cursor from --unlock ... --cursor)
-> Enter -> skip the fly-by -> gameplay. Returns timings."""
T = {}
t0 = tx = time.time()
@@ -528,10 +536,14 @@ class Session:
raise Crash(c)
if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")):
log("main menu")
if on_menu:
on_menu()
time.sleep(0.5)
self.key("Return")
self.key("Return", 0.6)
break
if fuzzy_in("gespeichertes", self.slot_text()):
if on_menu:
on_menu()
break
if time.time() - tx > 40:
log("no menu yet -> Return (skips logo/demo)")
@@ -541,21 +553,15 @@ class Session:
raise Timeout("main menu not reached")
time.sleep(1)
T["menu"] = round(time.time() - t0, 1)
self.wait_for(lambda: fuzzy_in("gespeichertes", self.slot_text()), 20, "save slot screen")
time.sleep(0.4)
for _ in range(2): # 3-slot slider without wrap: slot 3 is the rightmost
self.key("Right", 0.3)
time.sleep(0.3)
for i in range(3):
time.sleep(0.6)
t = self.slot_text()
if fuzzy_in("kapitel", t) and not fuzzy_in("leer", t):
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")
self._to_slot_screen()
t = self._select_slot(slot)
if not self._slot_ok(t):
# an empty slot starts a new game in the village: never the level that was asked for
raise Timeout("save slot %d is empty (%s) - unlock it first" % (slot, t))
name = SLOT_WORLD_NAMES.get(world)
if name and not fuzzy_in(name, t):
raise Timeout("save slot %d holds another world (%s), expected %r" % (slot, t, name))
self._open_slot()
T["slot"] = round(time.time() - t0, 1)
if world == 9:
self.wait_for(lambda: self.classify() == "map", 30, "world 9 map")
@@ -563,10 +569,11 @@ class Session:
else:
self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map")
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["map"] = round(t_load - t0, 1)
skipped, good, states = False, 0, []
skipped, good, states, again = False, 0, [], 0
while time.time() - t_load < 150:
c = self.crashed()
if c:
@@ -575,7 +582,12 @@ class Session:
if not states or states[-1] != st:
states.append(st)
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)
self.key("Return")
skipped = True
@@ -596,6 +608,79 @@ class Session:
log("gameplay after %.1f s, states %s" % (T["gameplay"], states))
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):
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,
@@ -606,18 +691,53 @@ class Session:
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)
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
try:
sess.setup()
report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:]
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.start()
if level:
report["tolevel"] = sess.tolevel(world=level[0])
start_to_level(sess, level, out, report, retry)
sess.shot(os.path.join(out, "00_start.png"))
if seq:
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)
if not keep:
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
@@ -660,7 +781,8 @@ def main(argv):
i = 0
while i < len(rest):
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]
i += 2
elif a == "--opt" and i + 1 < len(rest):
@@ -685,7 +807,8 @@ def main(argv):
elif cmd == "start":
s.start()
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":
print(s.keys(pos, outdir=kw.get("out")))
elif cmd == "shot":
@@ -703,7 +826,8 @@ def main(argv):
print("run needs --display N and --out DIR")
return 2
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:
print(__doc__)
return 2
+11 -2
View File
@@ -9,7 +9,8 @@ Writes to outdir:
page_NN.png texture pages (256x256, from TEXT)
collision.obj (--collision) collision polygons, grouped by material
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
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
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):
Wd, Hd = size
eye, target, fov = cam
@@ -237,10 +243,13 @@ def main():
for k, im in enumerate(imgs):
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'))
# 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]])
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 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,
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,
+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()