From c12db01b249c3c9fc7c23eaae442819918a19cbf Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:42:04 +0200 Subject: [PATCH 1/3] The current world is read by its owner, which the World page and the tree show docs/SYSTEMS.md, section 2. - WorldReading owns the instance's current world: the one the game has open, otherwise the one played last. It reads the world's folder as a FileReading, again when the game saves the world, opens or leaves it, or its datapacks folder changes, and moves to another world when the game plays one. - The World page follows it and the datapacks the game names, as a page of the loader: it reads when first shown and after a change, not whenever it is shown. - The Project tree's World rows show what the owner read; they no longer read level.dat themselves. - WorldReadings, through which the page told the tree what it read, goes. - FileReading can follow a whole folder, some of whose entries make up the value. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../catalog/WorldReading.java | 100 ++++++++++++++++++ .../catalog/WorldReadings.java | 49 --------- .../project/ProjectScope.java | 11 +- .../ui/components/catalog/WorldPanel.java | 43 ++++---- .../ui/components/treeView/FileTreeView.java | 4 +- .../components/treeView/WorldTreeItems.java | 26 ++--- .../totalDebugCompanion/util/FileReading.java | 22 +++- .../totalDebugCompanion/SystemsRulesTest.java | 1 - .../catalog/WorldReadingTest.java | 78 ++++++++++++++ .../catalog/WorldReadingsTest.java | 32 ------ .../navigation/PageReadsTest.java | 45 ++++++++ 11 files changed, 277 insertions(+), 134 deletions(-) create mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java delete mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadings.java create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java delete mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingsTest.java diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java new file mode 100644 index 00000000..8cb23721 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java @@ -0,0 +1,100 @@ +package com.github.minecraft_ta.totalDebugCompanion.catalog; + +import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; +import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; +import com.github.minecraft_ta.totalDebugCompanion.util.Strand; +import com.github.minecraft_ta.totalDebugCompanion.util.Workers; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * The instance's current world: the one the game has open, otherwise the one played last, read from its folder + * (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself. + * It moves to another world when the game plays one, and reads its world again when the game saves it, opens or leaves + * it, or its datapacks folder changes. Which world is current is decided on its strand. + */ +public final class WorldReading implements AutoCloseable { + /** The entries of a world's folder that make up what is read of it. */ + private static final Set ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks"); + + /** The current world: its folder and what its level.dat holds, or why there is nothing to show. */ + public record World(Path directory, CurrentWorld.Saved saved, String problem) { + } + + /** The world followed and its reading, published together. */ + private record Followed(Path directory, FileReading reading) { + } + + private final GameLocation location; + private final Signal changed = new Signal(); + private final Strand strand = Workers.strand(); + private final Runnable stopFollowingGame; + private volatile Followed followed = new Followed(null, null); + // Changed on the strand only. + private Runnable stopReading = () -> { }; + private boolean closed; + + /** Follows the current world of the game {@code location} tells of. Reads which world that is now. Blocking. */ + public WorldReading(GameLocation location) { + this.location = Objects.requireNonNull(location, "location"); + follow(); + this.stopFollowingGame = location.addListener(change -> this.strand.execute(this::follow)); + } + + /** Fires after the current world changed or what it holds did. */ + public Signal changed() { + return this.changed; + } + + /** The current world as read last; read now where it was not read yet. Blocking then. */ + public World value() { + Followed now = this.followed; + if (now.directory() == null) return new World(null, null, "No world has been played in this instance yet."); + try { + return new World(now.directory(), now.reading().value(), ""); + } catch (IOException unreadable) { + return new World(now.directory(), null, + "The world " + now.directory().getFileName() + " could not be read: " + unreadable.getMessage()); + } + } + + /** Finds which world is current now and follows it; the one followed so far is read again, as it may have opened. */ + private void follow() { + if (this.closed) return; + Path current = CurrentWorld.directory(this.location.read()).orElse(null); + Followed before = this.followed; + if (Objects.equals(current, before.directory())) { + if (before.reading() != null) before.reading().readNow(); + return; + } + this.stopReading.run(); + if (current == null) { + this.stopReading = () -> { }; + this.followed = new Followed(null, null); + } else { + FileReading reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), + world -> CurrentWorld.read(this.location.read(), world), Duration.ofMillis(500)); + Runnable stopTelling = reading.changed().subscribe(this.changed::fire); + this.stopReading = () -> { + stopTelling.run(); + reading.close(); + }; + this.followed = new Followed(current, reading); + } + if (before.directory() != null || current != null) this.changed.fire(); + } + + @Override + public void close() { + this.stopFollowingGame.run(); + this.strand.execute(() -> { + this.closed = true; + this.stopReading.run(); + }); + } +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadings.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadings.java deleted file mode 100644 index 1f0200cd..00000000 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadings.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.catalog; - -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * What was last read of the current world, so the views that show it follow one another: the World page reads the - * world whenever it is shown, and the Project tree loads its World rows again when that read found something other than - * what the tree shows. - */ -public final class WorldReadings { - /** What the tree shows of a world: its folder and how many rules and datapacks it has. */ - public record Summary(Path directory, int gameRules, int datapacks) { - /** No world, or one that could not be read. */ - public static final Summary NONE = new Summary(null, 0, 0); - - public static Summary of(CurrentWorld.Saved saved) { - return saved == null ? NONE : new Summary(saved.directory(), saved.gameRules().size(), saved.datapacks().size()); - } - } - - private final List listeners = new CopyOnWriteArrayList<>(); - private Summary last; - - /** - * Records what the World page read; the listeners hear of it when it differs from the read before, or when it is the - * first, since the tree may be reading the world at the same time. Any thread. - */ - public void read(Summary summary) { - Summary before; - synchronized (this) { - before = this.last; - this.last = summary; - } - if (!summary.equals(before)) this.listeners.forEach(Runnable::run); - } - - /** Records what the Project tree shows, so a read that finds the same asks for nothing; the listeners hear nothing. */ - public synchronized void shown(Summary summary) { - this.last = summary; - } - - /** Adds a listener, called on the thread that recorded the read; returns what removes it. */ - public Runnable addListener(Runnable listener) { - this.listeners.add(listener); - return () -> this.listeners.remove(listener); - } -} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java index 262f0f34..cfd3150d 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.project; +import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading; import com.github.minecraft_ta.totaldebug.protocol.scnet.RelayFailedMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage; @@ -11,7 +12,6 @@ import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigLabels; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingLabels; -import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReadings; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigChanges; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl; @@ -90,9 +90,9 @@ public List changeLabels() { /** The packs the game uses, as it names them or its files enable them. */ public GamePacks packs() { return packs; } private final ResourceEdits resources; - private final WorldReadings world = new WorldReadings(); - /** What was last read of the current world, which the World page and the Project tree follow. */ - public WorldReadings world() { return world; } + private final WorldReading world; + /** The current world, which the World page and the Project tree show. */ + public WorldReading world() { return world; } /** Writes edited resources into the packs Companion manages and reloads them in the running game. */ public ResourceEdits resources() { return resources; } private final PackSelections packSelections; @@ -118,6 +118,7 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state, this.keyAssignments = new KeyAssignments(profile.workspaceDirectory().resolve("options.txt")); this.keyBindings = new KeyBindingControl(this.pipeline, this.keyAssignments); this.packs = new GamePacks(this.location); + this.world = new WorldReading(this.location); this.resources = new ResourceEdits(this.pipeline, this.packs, new ResourceOriginals(paths().originals()), this.configChanges.writes(), state); this.packSelections = new PackSelections(this.resources); @@ -226,7 +227,7 @@ public List drainNavigations() { pending.clear(); } // Writes still queued finish first, so each is recorded before the change record closes. - try { keyAssignments.close(); resources.close(); configChanges.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } + try { keyAssignments.close(); world.close(); resources.close(); configChanges.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } } public String loadBreakpointScript(String name) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java index 60216d66..9018b73a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java @@ -1,7 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog; +import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading; import com.github.minecraft_ta.totalDebugCompanion.Icons; -import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReadings; import com.github.minecraft_ta.totalDebugCompanion.catalog.CurrentWorld; import com.github.minecraft_ta.totalDebugCompanion.game.GameState; import com.github.minecraft_ta.totalDebugCompanion.catalog.PackCatalogService; @@ -89,7 +89,7 @@ record ServerWorld(String address, Path world) { private final PackCatalogService catalog; private final ItemIconService icons; - private final WorldReadings readings; + private final WorldReading world; private final PageLoader loader; private final Runnable removeCatalogListener; private final SubjectHeader header = new SubjectHeader(); @@ -112,14 +112,11 @@ record ServerWorld(String address, Path world) { private WorldTab requested; private boolean disposed; - /** - * Shows the current world of the game {@code edits} tells of; each read is recorded in {@code readings}, which the - * Project tree follows. - */ - public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReadings readings, + /** Shows {@code world}, the current world, or the world of the server the game {@code edits} tells of plays on. */ + public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReading world, ResourceEdits edits, PackSelections selections, Consumer navigator) { super(new BorderLayout()); - this.readings = Objects.requireNonNull(readings, "readings"); + this.world = Objects.requireNonNull(world, "world"); this.catalog = Objects.requireNonNull(catalog, "catalog"); this.icons = Objects.requireNonNull(icons, "icons"); this.datapacks = new PacksPanel(PacksPanel.Side.DATA, Objects.requireNonNull(navigator, "navigator")); @@ -153,28 +150,23 @@ public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReadin this.removeCatalogListener = ShownUpdates.follow(this, catalog.changed()::subscribe, () -> { if (!this.disposed && (this.saved != null || this.server != null)) this.datapacks.setPacks(this.datapackList, this.catalog.index().orElse(null)); }); - // The game saves the world while it runs, so the page reads it whenever it is shown. A change of the datapacks, - // which also comes with another world or a disconnect, is read at once, shown or not: the tab names the world or - // server it reads. + // The world's owner reads it when the game saves it or plays another; the datapacks the connected game names + // come with its packs. this.loader = new PageLoader<>(() -> { PackStackPayload stack = edits.packs().datapacks(); String refusal = edits.packs().worldRefusal(); - return () -> read(edits.location().read(), stack, refusal); + return () -> read(edits.location().read(), this.world, stack, refusal); }, this::show, failure -> show(Loaded.problem("The world could not be read: " + failure.getMessage()))) - .readsWhenShown(this).follow(edits.packs().changed(ChangeRecord.PackSide.DATA)::subscribe); + .page(this).follows(world.changed()).follows(edits.packs().changed(ChangeRecord.PackSide.DATA)); } - private static Loaded read(GameState game, PackStackPayload stack, String refusal) { + private static Loaded read(GameState game, WorldReading world, PackStackPayload stack, String refusal) throws IOException { Optional server = game.server(); if (server.isPresent()) return readServer(game, server.get(), stack, refusal); - Optional world = CurrentWorld.directory(game); - if (world.isEmpty()) return Loaded.problem("No world has been played in this instance yet."); - try { - CurrentWorld.Saved saved = CurrentWorld.read(game, world.get()); - return new Loaded(saved, null, PackResources.worldDatapacks(stack, saved), icon(world.get().resolve("icon.png")), ""); - } catch (IOException | RuntimeException unreadable) { - return Loaded.problem("The world " + world.get().getFileName() + " could not be read: " + unreadable.getMessage()); - } + WorldReading.World current = world.value(); + if (current.saved() == null) return Loaded.problem(current.problem()); + CurrentWorld.Saved saved = current.saved(); + return new Loaded(saved, null, PackResources.worldDatapacks(stack, saved), icon(current.directory().resolve("icon.png")), ""); } /** @@ -208,8 +200,6 @@ private void show(Loaded loaded) { // Changes staged for another world, such as the server played before, would otherwise be applied to this one. if (!Objects.equals(world, this.shownWorld)) this.datapacks.discardChanges(); this.shownWorld = world; - this.readings.read(this.server != null ? new WorldReadings.Summary(this.server.world(), 0, this.datapackList.size()) - : WorldReadings.Summary.of(this.saved)); if (this.server != null) { showServer(this.server); return; @@ -349,6 +339,11 @@ private static JScrollPane scroll(JComponent component) { return scroll; } + /** How many times the page read the world, which tests count. */ + public int reads() { + return this.loader.reads(); + } + public void dispose() { this.disposed = true; this.loader.dispose(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java index 1757a2db..c2ab5ca3 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java @@ -266,12 +266,12 @@ public List loadChildren() { this.tree.refreshRootNodes(roots); } else { this.tree.setRootNodes(roots); - // The World rows follow what the World page read last, and the game to another world. + // The World rows follow the current world as its owner read it, and the game to another world. this.removeWorldListener.run(); Runnable refresh = () -> SwingUtilities.invokeLater(() -> { if (!this.disposed && project.get() == scope) this.tree.refreshRoot(WorldTreeItems.ROOT); }); - Runnable removeRead = scope.world().addListener(refresh); + Runnable removeRead = scope.world().changed().subscribe(refresh); // What the game plays also decides whether there is a World root at all. Runnable removePlayed = scope.location().addListener(change -> { if (change != GameLocation.Change.PLAYING) return; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java index e40f2e8d..d1c8cc5f 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java @@ -1,7 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; +import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading; import com.github.minecraft_ta.totalDebugCompanion.Icons; -import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReadings; import com.github.minecraft_ta.totalDebugCompanion.catalog.CurrentWorld; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.game.GameState; @@ -17,8 +17,6 @@ import com.github.minecraft_ta.totaldebug.protocol.message.PackStackPayload; import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; -import java.io.IOException; -import java.nio.file.Path; import java.text.NumberFormat; import java.util.ArrayList; import java.util.List; @@ -26,9 +24,8 @@ import java.util.Optional; /** - * The World tree: the current world, which opens its page, with rows for its game rules and datapacks. The world is - * read when the tree loads its rows, in the background; the tree loads them again when the World page read something - * else (see {@link WorldReadings}). + * The World tree: the current world, which opens its page, with rows for its game rules and datapacks, as the world's + * owner read it ({@link WorldReading}); the tree loads its rows again when that changed. */ final class WorldTreeItems { static final String ROOT = "world"; @@ -43,13 +40,13 @@ static String rowName(WorldTab tab) { static final class Root extends DirectoryTreeItem implements NavigableTreeItem { private final GameLocation location; - private final WorldReadings readings; + private final WorldReading world; private final GamePacks packs; - Root(GameLocation location, WorldReadings readings, GamePacks packs) { + Root(GameLocation location, WorldReading world, GamePacks packs) { super(ROOT); this.location = location; - this.readings = readings; + this.world = world; this.packs = packs; setPresentation(PrimarySecondaryText.primary("World")); setIcon(Icons.WORLD); @@ -78,17 +75,10 @@ public List loadChildren() { // The server's world, as the server names its datapacks. PackStackPayload datapacks = this.packs.datapacks(); int count = datapacks == null || !server.get().totalDebug() ? 0 : PackResources.serverDatapacks(datapacks).size(); - this.readings.shown(new WorldReadings.Summary(game.serverWorld(server.get()), 0, count)); return count == 0 ? List.of() : List.of(new Tab(WorldTab.DATAPACKS, count)); } - Optional world = CurrentWorld.directory(game); - CurrentWorld.Saved saved = null; - try { - if (world.isPresent()) saved = CurrentWorld.read(game, world.get()); - } catch (IOException | RuntimeException unreadable) { - // The page says why the world could not be read. - } - this.readings.shown(WorldReadings.Summary.of(saved)); + // As its owner read it last; the page says why a world could not be read. + CurrentWorld.Saved saved = this.world.value().saved(); if (saved == null) return List.of(); List children = new ArrayList<>(); if (!saved.gameRules().isEmpty()) children.add(new Tab(WorldTab.GAME_RULES, saved.gameRules().size())); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java index 80415bd0..282e1fe9 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java @@ -10,6 +10,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; /** * The owner of a value read from a file others write, such as the keys {@code options.txt} assigns (docs/SYSTEMS.md, @@ -19,9 +20,11 @@ *

A write is read once it has settled, as a file can be written in parts. A read that fails keeps the value read * before, and is tried again after 1, 5 and 30 seconds and at the next write; the next read that succeeds is told, since a * page may show the failure. The file's folder is followed through {@link FileWatch}, also before it exists.

+ * + *

A reading may also follow a whole folder, such as a world's, where some of its entries make up the value.

*/ public final class FileReading implements AutoCloseable { - /** Reads the value a file holds; a missing file is a value too, such as nothing assigned. Blocking. */ + /** Reads the value a file or folder holds; a missing one is a value too, such as nothing assigned. Blocking. */ @FunctionalInterface public interface Reader { T read(Path file) throws IOException; @@ -34,6 +37,8 @@ private record Read(T value) { } private final Reader reader; + /** For a reading of a folder, the names of its entries that make up the value; null for a reading of a file. */ + private final Predicate entries; private final long settleMillis; private final Signal changed = new Signal(); private final Strand strand = Workers.strand(); @@ -54,9 +59,18 @@ private record Read(T value) { /** Reads {@code file} with {@code reader}, and again after each write that stopped for {@code settle}. */ public FileReading(Path file, Reader reader, Duration settle) { + this(file, null, reader, settle); + } + + /** + * Reads {@code folder} with {@code reader}, and again after each change of an entry that {@code entries} accepts that + * stopped for {@code settle}. + */ + public FileReading(Path folder, Predicate entries, Reader reader, Duration settle) { this.reader = Objects.requireNonNull(reader, "reader"); + this.entries = entries; this.settleMillis = settle.toMillis(); - Path followed = Objects.requireNonNull(file, "file").toAbsolutePath().normalize(); + Path followed = Objects.requireNonNull(folder, "folder").toAbsolutePath().normalize(); long generation = this.generation.incrementAndGet(); this.strand.execute(() -> follow(followed, generation)); } @@ -122,7 +136,9 @@ private void follow(Path followed, long generation) { this.unwatch.run(); this.file = followed; Path name = followed.getFileName(); - this.unwatch = FileWatch.shared().watch(followed.getParent(), name::equals, this::written); + this.unwatch = this.entries != null + ? FileWatch.shared().watch(followed, this.entries, this::written) + : FileWatch.shared().watch(followed.getParent(), name::equals, this::written); read(0, generation); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java index 290bd5f8..1556518c 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java @@ -137,7 +137,6 @@ private record Allowed(int times, String why) { Map.entry("game/GameLocation.java", new Allowed(1, "moves onto signals in PR 3")), Map.entry("inspection/ItemIconService.java", new Allowed(1, "moves onto signals in PR 3")), Map.entry("runtime/RuntimeIndexService.java", new Allowed(1, "moves onto signals in PR 3")), - Map.entry("catalog/WorldReadings.java", new Allowed(1, "replaced by CurrentWorld in PR 4")), Map.entry("util/FileUtils.java", new Allowed(1, "becomes FileWatch in PR 4")), Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 5"))); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java new file mode 100644 index 00000000..a763c579 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java @@ -0,0 +1,78 @@ +package com.github.minecraft_ta.totalDebugCompanion.catalog; + +import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; +import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The current world, read by its one owner, which the World page and the Project tree show. */ +class WorldReadingTest { + @TempDir Path directory; + + @Test + void theWorldPlayedLastIsReadAgainWhenTheGameSavesIt() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + AtomicInteger told = new AtomicInteger(); + try (WorldReading reading = new WorldReading(new GameLocation(this.directory))) { + reading.changed().subscribe(told::incrementAndGet); + assertEquals("World", reading.value().saved().name()); + assertEquals(3, reading.value().saved().gameRules().size()); + + // The game saves the world with a rule changed. + Map saved = LevelDatFixture.world("World"); + ((Map) saved.get("GameRules")).put("keepInventory", "false"); + LevelDatFixture.write(world, saved); + await(() -> told.get() >= 1); + assertEquals("false", reading.value().saved().gameRules().get("keepInventory")); + } + } + + @Test + void theWorldTheGamePlaysIsFollowedAndTheOneBeforeTellsNothing() throws Exception { + Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); + Path second = LevelDatFixture.write(this.directory.resolve("saves/Second"), LevelDatFixture.world("Second")).getParent(); + Files.setLastModifiedTime(second.resolve("level.dat"), Files.getLastModifiedTime(first.resolve("level.dat"))); + GameLocation location = new GameLocation(this.directory); + AtomicInteger told = new AtomicInteger(); + try (WorldReading reading = new WorldReading(location)) { + reading.changed().subscribe(told::incrementAndGet); + reading.value(); + + // The game connects and opens the second world. + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(second.toString())); + await(() -> second.equals(reading.value().directory())); + assertEquals("Second", reading.value().saved().name()); + assertTrue(reading.value().saved().open(), "the world the game has open is read as open"); + int before = told.get(); + + Map other = LevelDatFixture.world("First renamed"); + LevelDatFixture.write(first, other); + Thread.sleep(1_000); + assertEquals(before, told.get(), "the world followed before tells nothing any more"); + } + } + + @Test + void withoutAWorldThePageIsToldWhy() { + try (WorldReading reading = new WorldReading(new GameLocation(this.directory))) { + assertEquals("No world has been played in this instance yet.", reading.value().problem()); + } + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(condition.getAsBoolean(), "timed out"); + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingsTest.java deleted file mode 100644 index 97110088..00000000 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingsTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.catalog; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class WorldReadingsTest { - @Test - void listenersHearOfAReadThatFoundSomethingElse() { - WorldReadings readings = new WorldReadings(); - AtomicInteger changes = new AtomicInteger(); - Runnable remove = readings.addListener(changes::incrementAndGet); - WorldReadings.Summary world = new WorldReadings.Summary(Path.of("saves/World"), 59, 15); - - readings.read(world); - assertEquals(1, changes.get(), "the tree may be reading the world at the same time, so the first read counts"); - readings.read(world); - assertEquals(1, changes.get(), "reading the same world again changes nothing"); - readings.shown(world); - readings.read(new WorldReadings.Summary(Path.of("saves/World"), 59, 16)); - assertEquals(2, changes.get(), "a datapack was added since the tree read the world"); - readings.read(WorldReadings.Summary.NONE); - assertEquals(3, changes.get(), "the world could not be read any more"); - - remove.run(); - readings.read(world); - assertEquals(3, changes.get()); - } -} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java index b031d3ac..469882b6 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java @@ -1,5 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.navigation; +import java.util.Map; +import com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog.WorldPanel; +import com.github.minecraft_ta.totalDebugCompanion.model.WorldView; +import com.github.minecraft_ta.totalDebugCompanion.catalog.LevelDatFixture; import com.github.minecraft_ta.totalDebugCompanion.CompanionApplication; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; import com.github.minecraft_ta.totalDebugCompanion.GlobalConfig; @@ -72,6 +76,47 @@ void theKeyBindingsPageReadsOnceWhenOpenedAndAgainOnlyAfterAChangeItMissed() thr } } + @Test + void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() throws Exception { + Path home = Files.createDirectory(this.directory.resolve("home")); + GlobalConfig.getInstance().loadFrom(home); + Path game = Files.createDirectory(this.directory.resolve("game")); + Path world = LevelDatFixture.write(game.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + try (CompanionApplication app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { + app.openProject(CompanionProfile.forGame(game)).get(10, TimeUnit.SECONDS); + MainWindow window = UiTestScope.onEdt(app::createWindow); + UiTestScope.onEdt(() -> { + window.setSize(1280, 720); + UiTestScope.show(window); + }); + + open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); + WorldPanel panel = UiTestScope.onEdt(() -> (WorldPanel) assertInstanceOf(WorldView.class, + window.getEditorTabs().getSelectedEditor()).getComponent()); + UiTestScope.await(() -> panel.reads() == 1); + settle(); + assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "opening the page reads it once"); + + open(window, new NavigationTarget.World(WorldTab.GAME_RULES)); + assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "navigating to the page it shows reads nothing"); + + open(window, new NavigationTarget.Changes()); + Map saved = LevelDatFixture.world("World"); + ((Map) saved.get("GameRules")).put("keepInventory", "false"); + LevelDatFixture.write(world, saved); + Thread.sleep(1_500); + assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "a hidden page does not read, though the game saved the world"); + open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); + UiTestScope.await(() -> panel.reads() == 2); + settle(); + assertEquals(2, (int) UiTestScope.onEdt(panel::reads), "shown again, it reads the saved world once"); + + open(window, new NavigationTarget.Changes()); + open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); + assertEquals(2, (int) UiTestScope.onEdt(panel::reads), "shown again without a change, it reads nothing"); + } + } + private static void open(MainWindow window, NavigationTarget target) throws Exception { window.navigation().navigate(target, NavigationService.Activation.KEEP_CURRENT_WINDOW).get(5, TimeUnit.SECONDS); settle(); From 95d8100eaceeb778857d545c80c060a93f3dcd10 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:23:11 +0200 Subject: [PATCH 2/3] The world's folder is watched only while the game holds it; saves, icon and datapacks are followed Which world is current is decided again when a world of saves changes, as when a new world is created or the current one deleted, and after Companion changed the datapacks. The world's own folder and its datapacks are watched only while the game holds it, so the game's Delete World can remove a world Companion shows. A new icon is a change. A follower whose name filter fails no longer stops the watcher thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../catalog/WorldReading.java | 79 +++++++++++++--- .../project/ProjectScope.java | 2 +- .../totalDebugCompanion/util/FileReading.java | 34 ++++++- .../totalDebugCompanion/util/FileWatch.java | 13 ++- .../catalog/LevelDatFixture.java | 37 ++++++++ .../catalog/WorldReadingTest.java | 92 ++++++++++++++++++- .../navigation/PageReadsTest.java | 12 ++- .../util/FileReadingTest.java | 4 + 8 files changed, 246 insertions(+), 27 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java index 8cb23721..41f511b9 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java @@ -1,5 +1,10 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; +import java.nio.file.attribute.FileTime; +import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; +import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; +import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; +import com.github.minecraft_ta.totalDebugCompanion.game.GameState; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; @@ -7,6 +12,7 @@ import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Objects; @@ -15,34 +21,54 @@ /** * The instance's current world: the one the game has open, otherwise the one played last, read from its folder * (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself. - * It moves to another world when the game plays one, and reads its world again when the game saves it, opens or leaves - * it, or its datapacks folder changes. Which world is current is decided on its strand. + * Which world is current is decided on its strand, again when the game connects, plays another world or leaves one, and + * when a world of {@code saves} changes, as when a game Companion is not connected to opens or saves one. The current + * world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the game + * holds it: on Windows a watched folder cannot be deleted or renamed, as by the game's Delete World. */ public final class WorldReading implements AutoCloseable { /** The entries of a world's folder that make up what is read of it. */ - private static final Set ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks"); + private static final long SETTLE_MILLIS = 500; + private static final Set ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks", "icon.png"); /** The current world: its folder and what its level.dat holds, or why there is nothing to show. */ public record World(Path directory, CurrentWorld.Saved saved, String problem) { } + /** What is read of a world's folder: its level.dat, and when its icon was written, so a new icon is a change too. */ + private record Read(CurrentWorld.Saved saved, FileTime icon) { + } + /** The world followed and its reading, published together. */ - private record Followed(Path directory, FileReading reading) { + private record Followed(Path directory, FileReading reading) { } private final GameLocation location; private final Signal changed = new Signal(); + private final Runnable stopFollowingSaves; + private final Runnable stopFollowingDatapacks; private final Strand strand = Workers.strand(); private final Runnable stopFollowingGame; private volatile Followed followed = new Followed(null, null); // Changed on the strand only. private Runnable stopReading = () -> { }; + /** The open world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */ + private Path datapacksOf; + private Runnable stopDatapacks = () -> { }; private boolean closed; - /** Follows the current world of the game {@code location} tells of. Reads which world that is now. Blocking. */ - public WorldReading(GameLocation location) { + /** + * Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. Reads which + * world that is now. Blocking. + */ + public WorldReading(GameLocation location, GamePacks packs) { this.location = Objects.requireNonNull(location, "location"); follow(); + // A game opening a world creates its session lock before it takes it: which world is open is looked at after that. + this.stopFollowingSaves = FileWatch.shared().watch(location.workspace().resolve("saves"), world -> true, + () -> Workers.later(SETTLE_MILLIS, this.strand, this::follow)); + // Companion changed the datapacks, also in the level.dat of a world the game does not hold. + this.stopFollowingDatapacks = packs.changed(ChangeRecord.PackSide.DATA).subscribe(() -> this.strand.execute(this::follow)); this.stopFollowingGame = location.addListener(change -> this.strand.execute(this::follow)); } @@ -56,7 +82,7 @@ public World value() { Followed now = this.followed; if (now.directory() == null) return new World(null, null, "No world has been played in this instance yet."); try { - return new World(now.directory(), now.reading().value(), ""); + return new World(now.directory(), now.reading().value().saved(), ""); } catch (IOException unreadable) { return new World(now.directory(), null, "The world " + now.directory().getFileName() + " could not be read: " + unreadable.getMessage()); @@ -66,19 +92,28 @@ public World value() { /** Finds which world is current now and follows it; the one followed so far is read again, as it may have opened. */ private void follow() { if (this.closed) return; - Path current = CurrentWorld.directory(this.location.read()).orElse(null); + GameState game = this.location.read(); + Path current = CurrentWorld.directory(game).orElse(null); Followed before = this.followed; + boolean open = current != null && game.isOpen(current); if (Objects.equals(current, before.directory())) { - if (before.reading() != null) before.reading().readNow(); + if (before.reading() != null) { + before.reading().watch(open); + watchDatapacks(open ? current : null, before.reading()); + before.reading().readNow(); + } return; } this.stopReading.run(); if (current == null) { + watchDatapacks(null, null); this.stopReading = () -> { }; this.followed = new Followed(null, null); } else { - FileReading reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), - world -> CurrentWorld.read(this.location.read(), world), Duration.ofMillis(500)); + FileReading reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), + world -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS)); + reading.watch(open); + watchDatapacks(open ? current : null, reading); Runnable stopTelling = reading.changed().subscribe(this.changed::fire); this.stopReading = () -> { stopTelling.run(); @@ -89,11 +124,33 @@ private void follow() { if (before.directory() != null || current != null) this.changed.fire(); } + /** Watches the datapacks folder of {@code world}, which the game holds, or none; a change there reads it again. */ + private void watchDatapacks(Path world, FileReading reading) { + if (Objects.equals(world, this.datapacksOf)) return; + this.stopDatapacks.run(); + this.stopDatapacks = () -> { }; + this.datapacksOf = world; + if (world != null) this.stopDatapacks = FileWatch.shared().watch(world.resolve("datapacks"), entry -> true, reading::readNow); + } + + /** When the world's icon was written, or null without one. */ + private static FileTime iconWritten(Path world) { + try { + Path icon = world.resolve("icon.png"); + return Files.isRegularFile(icon) ? Files.getLastModifiedTime(icon) : null; + } catch (IOException unreadable) { + return null; + } + } + @Override public void close() { this.stopFollowingGame.run(); + this.stopFollowingSaves.run(); + this.stopFollowingDatapacks.run(); this.strand.execute(() -> { this.closed = true; + this.stopDatapacks.run(); this.stopReading.run(); }); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java index cfd3150d..2e422871 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java @@ -118,7 +118,7 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state, this.keyAssignments = new KeyAssignments(profile.workspaceDirectory().resolve("options.txt")); this.keyBindings = new KeyBindingControl(this.pipeline, this.keyAssignments); this.packs = new GamePacks(this.location); - this.world = new WorldReading(this.location); + this.world = new WorldReading(this.location, this.packs); this.resources = new ResourceEdits(this.pipeline, this.packs, new ResourceOriginals(paths().originals()), this.configChanges.writes(), state); this.packSelections = new PackSelections(this.resources); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java index 282e1fe9..a28c5793 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java @@ -47,6 +47,8 @@ private record Read(T value) { // Changed on the strand only. private Path file; private Runnable unwatch = () -> { }; + /** Whether the file is watched; one that is not is read only when asked, as a world the game does not hold. */ + private boolean watching = true; private ScheduledFuture pending; /** * Counts the reads asked for, counted where they are asked, also on the watcher's thread: a read waiting for a write @@ -131,17 +133,39 @@ public void close() { }); } + /** + * Watches the file, or stops watching it while it is read only when asked ({@link #readNow()}), as a world the game + * does not hold: on Windows, a watched folder cannot be deleted or renamed, as by the game's Delete World. + */ + public void watch(boolean watch) { + this.strand.execute(() -> { + if (this.closed || this.watching == watch) return; + this.watching = watch; + if (watch) register(); + else unregister(); + }); + } + private void follow(Path followed, long generation) { if (this.closed) return; - this.unwatch.run(); + unregister(); this.file = followed; - Path name = followed.getFileName(); - this.unwatch = this.entries != null - ? FileWatch.shared().watch(followed, this.entries, this::written) - : FileWatch.shared().watch(followed.getParent(), name::equals, this::written); + if (this.watching) register(); read(0, generation); } + private void register() { + Path name = this.file.getFileName(); + this.unwatch = this.entries != null + ? FileWatch.shared().watch(this.file, this.entries, this::written) + : FileWatch.shared().watch(this.file.getParent(), name::equals, this::written); + } + + private void unregister() { + this.unwatch.run(); + this.unwatch = () -> { }; + } + /** A write of the file was seen: it is read once no other came for the settle time. Any thread. */ private void written() { // Counted here, so a read under way gives way at once. diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java index c4a32f09..2ed0c21f 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java @@ -157,6 +157,17 @@ private void retryLater(Watched followed, int attempt) { }); } + /** Whether an entry named among {@code names} matters to {@code followed}; a follower's failing names tell nothing. */ + private static boolean concerns(Watched followed, List names) { + try { + return names.stream().anyMatch(followed.names); + } catch (RuntimeException failure) { + // One follower's failure does not stop the watch of every other. + LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched folder failed", failure); + return false; + } + } + private void run() { try { while (true) { @@ -181,7 +192,7 @@ private void run() { place(followed, 0); tell.add(followed.changed); } - } else if (lost || names.stream().anyMatch(followed.names)) { + } else if (lost || concerns(followed, names)) { tell.add(followed.changed); } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/LevelDatFixture.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/LevelDatFixture.java index d510edc9..0881b59a 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/LevelDatFixture.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/LevelDatFixture.java @@ -1,11 +1,16 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; +import com.github.minecraft_ta.totaldebug.storage.InstancePaths; + import java.io.DataOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -117,4 +122,36 @@ private static void writePayload(DataOutputStream output, Object value) throws I default -> throw new IllegalArgumentException("No NBT type for " + value); } } + + /** A running game that holds {@code world}, as the game's lock and the world's session lock say, until closed. */ + public static Held hold(Path world) throws IOException { + Path game = world.getParent().getParent(); + Path gameLock = InstancePaths.forGame(game).gameLock(); + Files.createDirectories(gameLock.getParent()); + return new Held(FileChannel.open(gameLock, StandardOpenOption.CREATE, StandardOpenOption.WRITE), + FileChannel.open(world.resolve("session.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)); + } + + /** The two locks a running game holding a world keeps. */ + public static final class Held implements AutoCloseable { + private final FileChannel game; + private final FileChannel world; + private final FileLock gameLock; + private final FileLock worldLock; + + Held(FileChannel game, FileChannel world) throws IOException { + this.game = game; + this.world = world; + this.gameLock = game.lock(); + this.worldLock = world.lock(); + } + + @Override + public void close() throws IOException { + this.worldLock.release(); + this.gameLock.release(); + this.world.close(); + this.game.close(); + } + } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java index a763c579..3801a4e0 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java @@ -5,6 +5,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; +import java.util.stream.Stream; +import java.util.Comparator; +import java.nio.file.attribute.FileTime; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; @@ -13,6 +17,7 @@ import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** The current world, read by its one owner, which the World page and the Project tree show. */ @@ -20,13 +25,14 @@ class WorldReadingTest { @TempDir Path directory; @Test - void theWorldPlayedLastIsReadAgainWhenTheGameSavesIt() throws Exception { + void theWorldTheGameHoldsIsReadAgainWhenTheGameSavesIt() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); AtomicInteger told = new AtomicInteger(); - try (WorldReading reading = new WorldReading(new GameLocation(this.directory))) { + try (LevelDatFixture.Held game = LevelDatFixture.hold(world); WorldReading reading = reading()) { reading.changed().subscribe(told::incrementAndGet); assertEquals("World", reading.value().saved().name()); - assertEquals(3, reading.value().saved().gameRules().size()); + assertTrue(reading.value().saved().open()); + Thread.sleep(300); // The game saves the world with a rule changed. Map saved = LevelDatFixture.world("World"); @@ -44,7 +50,7 @@ void theWorldTheGamePlaysIsFollowedAndTheOneBeforeTellsNothing() throws Exceptio Files.setLastModifiedTime(second.resolve("level.dat"), Files.getLastModifiedTime(first.resolve("level.dat"))); GameLocation location = new GameLocation(this.directory); AtomicInteger told = new AtomicInteger(); - try (WorldReading reading = new WorldReading(location)) { + try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { reading.changed().subscribe(told::incrementAndGet); reading.value(); @@ -65,11 +71,87 @@ void theWorldTheGamePlaysIsFollowedAndTheOneBeforeTellsNothing() throws Exceptio @Test void withoutAWorldThePageIsToldWhy() { - try (WorldReading reading = new WorldReading(new GameLocation(this.directory))) { + try (WorldReading reading = reading()) { assertEquals("No world has been played in this instance yet.", reading.value().problem()); } } + @Test + void aWorldTheGameDoesNotHoldCanBeDeletedWhileItIsShown() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + GameLocation location = new GameLocation(this.directory); + try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { + assertEquals("World", reading.value().saved().name()); + Thread.sleep(300); + // As the game's Delete World removes a world it does not hold. + try (Stream files = Files.walk(world)) { + for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); + } + assertFalse(Files.exists(world), "nothing of Companion holds the folder"); + } + } + + @Test + void aNewWorldAGameCompanionIsNotConnectedToOpensIsFollowed() throws Exception { + Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); + try (WorldReading reading = reading()) { + assertEquals(first, reading.value().directory()); + Thread.sleep(300); + + // A game Companion is not connected to creates a world and opens it. + Path second = Files.createDirectories(this.directory.resolve("saves/Second")); + try (LevelDatFixture.Held game = LevelDatFixture.hold(second)) { + LevelDatFixture.write(second, LevelDatFixture.world("Second")); + await(() -> second.equals(reading.value().directory())); + assertTrue(reading.value().saved().open(), "the world the game holds is read as open"); + } + } + } + + @Test + void theWorldPlayedBeforeIsFollowedWhenTheCurrentOneIsDeleted() throws Exception { + Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); + Path second = LevelDatFixture.write(this.directory.resolve("saves/Second"), LevelDatFixture.world("Second")).getParent(); + Files.setLastModifiedTime(first.resolve("level.dat"), FileTime.fromMillis(System.currentTimeMillis() - 60_000)); + try (WorldReading reading = reading()) { + assertEquals(second, reading.value().directory()); + Thread.sleep(300); + + // The game's Delete World removes the world played last. + try (Stream files = Files.walk(second)) { + for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); + } + await(() -> first.equals(reading.value().directory())); + } + } + + @Test + void aNewIconOrDatapackOfTheWorldTheGameHoldsIsAChange() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + GameLocation location = new GameLocation(this.directory); + AtomicInteger told = new AtomicInteger(); + try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(world.toString())); + await(() -> reading.value().saved() != null && reading.value().saved().open()); + reading.changed().subscribe(told::incrementAndGet); + Thread.sleep(700); + int before = told.get(); + + Files.write(world.resolve("icon.png"), new byte[]{1, 2, 3}); + await(() -> told.get() > before); + int afterIcon = told.get(); + LevelDatFixture.datapack(world, "Added"); + await(() -> told.get() > afterIcon); + } + } + + /** The current world of a game Companion is not connected to. */ + private WorldReading reading() { + GameLocation location = new GameLocation(this.directory); + return new WorldReading(location, new GamePacks(location)); + } + private static void await(BooleanSupplier condition) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java index 469882b6..6eabc361 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java @@ -101,10 +101,14 @@ void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() thro assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "navigating to the page it shows reads nothing"); open(window, new NavigationTarget.Changes()); - Map saved = LevelDatFixture.world("World"); - ((Map) saved.get("GameRules")).put("keepInventory", "false"); - LevelDatFixture.write(world, saved); - Thread.sleep(1_500); + // A game holds the world and saves it. + try (LevelDatFixture.Held running = LevelDatFixture.hold(world)) { + Thread.sleep(700); + Map saved = LevelDatFixture.world("World"); + ((Map) saved.get("GameRules")).put("keepInventory", "false"); + LevelDatFixture.write(world, saved); + Thread.sleep(1_500); + } assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "a hidden page does not read, though the game saved the world"); open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); UiTestScope.await(() -> panel.reads() == 2); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java index 227933e2..a432453a 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java @@ -299,6 +299,9 @@ void aFollowerThatFailsDoesNotStopTheWatchOfOthers() throws Exception { Runnable failing = FileWatch.shared().watch(folder, name -> true, () -> { throw new IllegalStateException("a follower's bug"); }); + Runnable failingNames = FileWatch.shared().watch(folder, name -> { + throw new IllegalStateException("a follower's bug"); + }, () -> { }); Runnable counting = FileWatch.shared().watch(folder, name -> true, told::incrementAndGet); try { write(folder.resolve("first.txt"), "a"); @@ -308,6 +311,7 @@ void aFollowerThatFailsDoesNotStopTheWatchOfOthers() throws Exception { await(() -> told.get() > before); } finally { failing.run(); + failingNames.run(); counting.run(); } } From fed8e238843cde30f47a8c33e80423da2b5cca41 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:49:02 +0200 Subject: [PATCH 3/3] A world's folder is watched only while the connected game plays it On Windows a watch of a folder inside a world, as its datapacks, keeps the world's folder from being deleted or moved. The connected game tells it left a world only once it let go of it, so the watch always ends in time; a game Companion is not connected to has its worlds read when Companion looks again. The owner's triggers are registered before its first look. SYSTEMS.md records readings that stop watching and the Windows rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../catalog/WorldReading.java | 23 ++++--- .../catalog/WorldReadingTest.java | 63 +++++++++++++++---- .../navigation/PageReadsTest.java | 19 +++--- docs/SYSTEMS.md | 4 +- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java index 41f511b9..d795aac8 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; +import java.util.concurrent.CompletableFuture; import java.nio.file.attribute.FileTime; import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; @@ -23,8 +24,9 @@ * (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself. * Which world is current is decided on its strand, again when the game connects, plays another world or leaves one, and * when a world of {@code saves} changes, as when a game Companion is not connected to opens or saves one. The current - * world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the game - * holds it: on Windows a watched folder cannot be deleted or renamed, as by the game's Delete World. + * world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the connected + * game plays it, which the game tells only once it let go of the world: on Windows a watched folder cannot be deleted or + * renamed, as by the game's Delete World. */ public final class WorldReading implements AutoCloseable { /** The entries of a world's folder that make up what is read of it. */ @@ -52,7 +54,7 @@ private record Followed(Path directory, FileReading reading) { private volatile Followed followed = new Followed(null, null); // Changed on the strand only. private Runnable stopReading = () -> { }; - /** The open world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */ + /** The played world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */ private Path datapacksOf; private Runnable stopDatapacks = () -> { }; private boolean closed; @@ -63,13 +65,14 @@ private record Followed(Path directory, FileReading reading) { */ public WorldReading(GameLocation location, GamePacks packs) { this.location = Objects.requireNonNull(location, "location"); - follow(); // A game opening a world creates its session lock before it takes it: which world is open is looked at after that. this.stopFollowingSaves = FileWatch.shared().watch(location.workspace().resolve("saves"), world -> true, () -> Workers.later(SETTLE_MILLIS, this.strand, this::follow)); // Companion changed the datapacks, also in the level.dat of a world the game does not hold. this.stopFollowingDatapacks = packs.changed(ChangeRecord.PackSide.DATA).subscribe(() -> this.strand.execute(this::follow)); this.stopFollowingGame = location.addListener(change -> this.strand.execute(this::follow)); + // Followed after the triggers are, so none is missed in between; on the strand, as every later look. + CompletableFuture.runAsync(this::follow, this.strand).join(); } /** Fires after the current world changed or what it holds did. */ @@ -95,11 +98,11 @@ private void follow() { GameState game = this.location.read(); Path current = CurrentWorld.directory(game).orElse(null); Followed before = this.followed; - boolean open = current != null && game.isOpen(current); + boolean played = current != null && game.plays(current); if (Objects.equals(current, before.directory())) { if (before.reading() != null) { - before.reading().watch(open); - watchDatapacks(open ? current : null, before.reading()); + before.reading().watch(played); + watchDatapacks(played ? current : null, before.reading()); before.reading().readNow(); } return; @@ -112,8 +115,8 @@ private void follow() { } else { FileReading reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), world -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS)); - reading.watch(open); - watchDatapacks(open ? current : null, reading); + reading.watch(played); + watchDatapacks(played ? current : null, reading); Runnable stopTelling = reading.changed().subscribe(this.changed::fire); this.stopReading = () -> { stopTelling.run(); @@ -124,7 +127,7 @@ private void follow() { if (before.directory() != null || current != null) this.changed.fire(); } - /** Watches the datapacks folder of {@code world}, which the game holds, or none; a change there reads it again. */ + /** Watches the datapacks folder of {@code world}, which the game plays, or none; a change there reads it again. */ private void watchDatapacks(Path world, FileReading reading) { if (Objects.equals(world, this.datapacksOf)) return; this.stopDatapacks.run(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java index 3801a4e0..13c169ca 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java @@ -25,14 +25,16 @@ class WorldReadingTest { @TempDir Path directory; @Test - void theWorldTheGameHoldsIsReadAgainWhenTheGameSavesIt() throws Exception { + void theWorldTheGamePlaysIsReadAgainWhenTheGameSavesIt() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + GameLocation location = new GameLocation(this.directory); AtomicInteger told = new AtomicInteger(); - try (LevelDatFixture.Held game = LevelDatFixture.hold(world); WorldReading reading = reading()) { + try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(world.toString())); + await(() -> reading.value().saved().open()); reading.changed().subscribe(told::incrementAndGet); - assertEquals("World", reading.value().saved().name()); - assertTrue(reading.value().saved().open()); - Thread.sleep(300); + Thread.sleep(700); // The game saves the world with a rule changed. Map saved = LevelDatFixture.world("World"); @@ -77,16 +79,53 @@ void withoutAWorldThePageIsToldWhy() { } @Test - void aWorldTheGameDoesNotHoldCanBeDeletedWhileItIsShown() throws Exception { + void aWorldAGameCompanionIsNotConnectedToHoldsCanBeMovedOnceItLeftIt() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + // On Windows a watch of a folder inside, as of the datapacks, holds the world's folder. + LevelDatFixture.datapack(world, "Pack"); + WorldReading reading = null; + try { + try (LevelDatFixture.Held game = LevelDatFixture.hold(world)) { + reading = reading(); + assertTrue(reading.value().saved().open()); + Thread.sleep(300); + } + // The game left the world; nothing of Companion holds its folder, which can be moved, as a backup does. + Files.move(world, this.directory.resolve("saves/Moved")); + assertFalse(Files.exists(world), "nothing of Companion holds the folder"); + } finally { + if (reading != null) reading.close(); + } + } + + @Test + void aWorldTheGameDoesNotPlayCanBeMovedWhileItIsShown() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + LevelDatFixture.datapack(world, "Pack"); + try (WorldReading reading = reading()) { + assertEquals("World", reading.value().saved().name()); + Thread.sleep(300); + Files.move(world, this.directory.resolve("saves/Moved")); + assertFalse(Files.exists(world), "nothing of Companion holds the folder"); + } + } + + @Test + void aWorldTheConnectedGameLeftCanBeMoved() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + LevelDatFixture.datapack(world, "Pack"); GameLocation location = new GameLocation(this.directory); try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { - assertEquals("World", reading.value().saved().name()); + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(world.toString())); + await(() -> reading.value().saved().open()); Thread.sleep(300); - // As the game's Delete World removes a world it does not hold. - try (Stream files = Files.walk(world)) { - for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); - } + + // The game tells it is in the menu once it let go of the world. + location.playing(new PlayingPayload.Menu()); + await(() -> !reading.value().saved().open()); + Thread.sleep(300); + Files.move(world, this.directory.resolve("saves/Moved")); assertFalse(Files.exists(world), "nothing of Companion holds the folder"); } } @@ -126,7 +165,7 @@ void theWorldPlayedBeforeIsFollowedWhenTheCurrentOneIsDeleted() throws Exception } @Test - void aNewIconOrDatapackOfTheWorldTheGameHoldsIsAChange() throws Exception { + void aNewIconOrDatapackOfTheWorldTheGamePlaysIsAChange() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); GameLocation location = new GameLocation(this.directory); AtomicInteger told = new AtomicInteger(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java index 6eabc361..d21caa0b 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.navigation; +import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; +import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import java.util.Map; import com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog.WorldPanel; import com.github.minecraft_ta.totalDebugCompanion.model.WorldView; @@ -84,6 +86,10 @@ void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() thro Path world = LevelDatFixture.write(game.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); try (CompanionApplication app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { app.openProject(CompanionProfile.forGame(game)).get(10, TimeUnit.SECONDS); + // The connected game plays the world. + GameLocation location = app.currentScope().location(); + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(world.toString())); MainWindow window = UiTestScope.onEdt(app::createWindow); UiTestScope.onEdt(() -> { window.setSize(1280, 720); @@ -101,14 +107,11 @@ void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() thro assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "navigating to the page it shows reads nothing"); open(window, new NavigationTarget.Changes()); - // A game holds the world and saves it. - try (LevelDatFixture.Held running = LevelDatFixture.hold(world)) { - Thread.sleep(700); - Map saved = LevelDatFixture.world("World"); - ((Map) saved.get("GameRules")).put("keepInventory", "false"); - LevelDatFixture.write(world, saved); - Thread.sleep(1_500); - } + // The game saves the world it plays. + Map saved = LevelDatFixture.world("World"); + ((Map) saved.get("GameRules")).put("keepInventory", "false"); + LevelDatFixture.write(world, saved); + Thread.sleep(1_500); assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "a hidden page does not read, though the game saved the world"); open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); UiTestScope.await(() -> panel.reads() == 2); diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 4feeb7d1..04ccb5d2 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -96,13 +96,13 @@ reading.moveTo(otherFile); // a folder that moves, as the current ``` - **Every read and every change of the value runs on the reading's strand**, whoever asks: the watch, a retry, Companion's own write, a page. So a page that asks before the first read gets the value later changes are told against. -- **One `FileWatch` for the application** does the watching, with one thread. A registration lives until its reading closes. +- **One `FileWatch` for the application** does the watching, with one thread. A registration lives until its reading closes or stops watching (`watch(false)`); a reading that does not watch keeps its value and reads only when its owner asks (`readNow`). - **A folder that does not exist yet** is watched through its nearest existing ancestor, and watched itself once it appears, as a game's folder before it first ran; one removed while watched is watched that way again. - **Where a folder cannot be watched at all**, as without file watching or on some network drives, it is tried again after 1 and 5 seconds and every 30 seconds after, and the reading reads each time. - **Events are filtered by name**, so `latest.log` growing beside `options.txt` does not read `options.txt`. - **A read that fails is retried** after 1, 5 and 30 seconds, as a file written in place can be read half written; the next read that succeeds is told, since a page may show the failure. Overflowing events read once. - **Folders that move are the owner's.** The current world follows `playing` and moves its reading to the world the game plays, or with the game closed, the last played. -- **Windows renames.** A watched folder and its ancestors cannot be renamed while watched. Registrations sit on the smallest folders that answer the question, and readings of a world move when the game leaves it. `FileUtils`' pause around Companion's own renames joins `FileWatch` when its watcher moves there. +- **Windows renames.** On Windows a folder cannot be renamed or deleted while a folder inside it is watched. Registrations sit on the smallest folders that answer the question. The current world's folder and its `datapacks` are watched only while the connected game plays it, which the game tells once it let go of the world, so its Delete World works; a world the game does not play is looked at again when the game connects or plays another world, when a world of `saves` is created or removed, and after Companion changed its datapacks. A game Companion is not connected to has its worlds read then, not watched. `FileUtils`' pause around Companion's own renames joins `FileWatch` when its watcher moves there. - **A reading's value is a domain value with a meaningful equality**, such as parsed assignments or a list of names; the reader supplies it. - **What each reading holds decides what fires.** The logs' reading holds the names of the logs and crash reports, not their sizes, so `latest.log` growing does not fire; the Logs page reads the log a row shows when the row is shown. A configuration folder's reading ignores Companion's own `*.totaldebug-original` files. - **Readings replace every "read whenever shown":** `options.txt`, configuration files, logs and crash reports, the current world's `level.dat` and icon, the `saves` and `resourcepacks` folders.