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..d795aac8 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java @@ -0,0 +1,160 @@ +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; +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; +import com.github.minecraft_ta.totalDebugCompanion.util.Strand; +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; +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. + * 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 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. */ + 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 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 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; + + /** + * 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"); + // 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. */ + 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().saved(), ""); + } 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; + GameState game = this.location.read(); + Path current = CurrentWorld.directory(game).orElse(null); + Followed before = this.followed; + boolean played = current != null && game.plays(current); + if (Objects.equals(current, before.directory())) { + if (before.reading() != null) { + before.reading().watch(played); + watchDatapacks(played ? 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 -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS)); + reading.watch(played); + watchDatapacks(played ? current : null, reading); + 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(); + } + + /** 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(); + 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/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..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 @@ -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.packs); 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..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 @@ -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(); @@ -42,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 @@ -54,9 +61,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)); } @@ -117,15 +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 = 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/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/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 new file mode 100644 index 00000000..13c169ca --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java @@ -0,0 +1,199 @@ +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 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; +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.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. */ +class WorldReadingTest { + @TempDir Path directory; + + @Test + 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 (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); + Thread.sleep(700); + + // 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, new GamePacks(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 = reading()) { + assertEquals("No world has been played in this instance yet.", reading.value().problem()); + } + } + + @Test + 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))) { + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(world.toString())); + await(() -> reading.value().saved().open()); + Thread.sleep(300); + + // 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"); + } + } + + @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 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(); + 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); + 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..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,11 @@ 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; +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 +78,52 @@ 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); + // 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); + 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()); + // 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); + 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(); 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(); } } 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.