diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java index 6159b9b1..c95a6242 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java @@ -48,13 +48,19 @@ public KeyBindingControl(ChangePipeline pipeline) { } /** - * Fires when the keys {@code options.txt} assigns changed, as read after Companion changed them or when the user came - * back to Companion, such as from the game's controls screen; another option written, such as the volume, tells nobody. + * Fires when the keys {@code options.txt} assigns changed, as read after Companion changed them, after the game told + * it saved changed keys, or when the user came back to Companion; another option written, such as the volume, tells + * nobody. */ public Signal assignmentsChanged() { return this.assignments.changed(); } + /** The game saved changed keys in {@code options.txt}, as after its controls screen closed: they are read again. */ + public void saved() { + this.assignments.refresh(); + } + /** The keys {@code options.txt} assigns, as read last; read now where none were read yet. Blocking then. */ public Map assignments() throws IOException { return this.assignments.value(); 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 9c9d7bca..f02ca6a8 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 @@ -3,6 +3,7 @@ import com.github.minecraft_ta.totalDebugCompanion.change.WriteQueue; 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.KeyAssignmentsMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage; @@ -135,6 +136,7 @@ public Runnable listen(MessageRoutes routes) { routes.on(PackStackMessage.class, message -> this.packs.named(message.payload())), routes.on(DatapacksMessage.class, message -> this.packs.datapacks(message.world(), message.payload(), message.refusal())), routes.on(PlayingMessage.class, message -> this.location.playing(message.payload())), + routes.on(KeyAssignmentsMessage.class, message -> this.keyBindings.saved()), // The refused message and its correlation name the request together. routes.on(RelayFailedMessage.class, message -> { switch (message.messageId()) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeMessagesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeMessagesTest.java index a9d67256..0fdb7678 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeMessagesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScopeMessagesTest.java @@ -4,14 +4,18 @@ import com.github.minecraft_ta.totalDebugCompanion.session.MessageRoutes; import com.github.minecraft_ta.totaldebug.protocol.message.ClientPacksPayload; import com.github.minecraft_ta.totaldebug.protocol.message.PackStackPayload; +import com.github.minecraft_ta.totaldebug.protocol.scnet.KeyAssignmentsMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage; import com.github.tth05.scnet.message.AbstractMessage; 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.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -41,6 +45,32 @@ void aProjectTakesItsMessagesUntilAnotherBecomesCurrent() throws Exception { } } + @Test + void theGameSavingChangedKeysHasTheKeysReadAgain() throws Exception { + Routes routes = new Routes(); + ProjectScope scope = ProjectScope.open(new Object(), CompanionProfile.forGame(this.directory)); + try { + Path options = scope.profile().workspaceDirectory().resolve("options.txt"); + Files.writeString(options, "key_key.jump:key.keyboard.space\n"); + Runnable stop = scope.listen(routes); + AtomicInteger told = new AtomicInteger(); + scope.keyBindings().assignmentsChanged().subscribe(told::incrementAndGet); + assertEquals("key.keyboard.space", scope.keyBindings().assignments().get("key.jump").encode()); + + // The player rebinds jump in the game's controls screen, which saves options.txt as it closes. + Files.writeString(options, "key_key.jump:key.keyboard.g\n"); + routes.deliver(new KeyAssignmentsMessage()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (told.get() == 0 && System.nanoTime() < deadline) Thread.sleep(10); + assertEquals(1, told.get(), "the Key bindings page and the change labels hear of it"); + assertEquals("key.keyboard.g", scope.keyBindings().assignments().get("key.jump").encode()); + stop.run(); + } finally { + scope.retire(); + scope.close(); + } + } + private static PackStackPayload stack(String pack) { return new PackStackPayload(34, List.of(new PackStackPayload.Pack(pack, pack, ""))); } diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 6369fe6b..4d826286 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -86,7 +86,7 @@ Nothing watches the files others write, apart from one case. Who wrote a file de | Who wrote it | How Companion learns of it | |---|---| -| The connected game | Its messages: packs, `PLAYING`, the answers to changes and reloads (section 6) | +| The connected game | Its messages: packs, `PLAYING`, the answers to changes and reloads, and that it saved changed key bindings (section 6) | | Companion | The owner reads again after the write and fires if the value differs (section 5) | | Another program, or a game without a connection | What shows the file reads it when it is shown, and again when the user comes back to Companion from another program (`WindowFocus.returned()`: a window of Companion taking the focus after Companion lost it to another program, which the main window tells of; Companion's first window opening is no return) | | A program Companion opened a file in | `FileWatch`, which takes its saves at once (below) | @@ -115,7 +115,7 @@ Its whole contract, which `FileReading`'s documentation states the same way: **Adopting a file is a write, not a reading.** A texture opened in another program from Companion is taken into the pack as that program saves it, while the user stays in the editor and looks at the game. This is the one watch: `FileWatch` tells `ExternalEdits` that a followed file was written, on its one thread; `ExternalEdits` keeps the file's baseline, waits until writes stopped, checks that the image is complete, and adopts it on the project's write queue through the pipeline. Before it records a change or asks for a reload, the adoption compares the saved content with what the pack already holds, by hash, and does nothing when they are the same: a notification of content Companion itself wrote, or a second notification of one save, must not record or reload again. Deduplication happens before side effects, in the owner, never after them in a page. A file whose folder cannot be watched is not opened, and the user is told why. Every opening watches the file anew, so one whose folder was removed and made again is followed again. -**Why nothing else is watched.** Decided on 2026-10-01, after #114 to #117 moved every watch onto one watcher. Watching folders that others own needed watches of ancestors for folders not there yet, real paths for links, retries, pauses so that Windows can rename or delete a watched folder (the game's Delete World), and settle times for files written in parts; most review findings of those PRs were about these. Reading when shown and when the user comes back answers the same question with none of them. A change that must show while Companion keeps the focus, as a key rebound in the game while Companion is visible beside it, comes as a game message, not as a watch. +**Why nothing else is watched.** Decided on 2026-10-01, after #114 to #117 moved every watch onto one watcher. Watching folders that others own needed watches of ancestors for folders not there yet, real paths for links, retries, pauses so that Windows can rename or delete a watched folder (the game's Delete World), and settle times for files written in parts; most review findings of those PRs were about these. Reading when shown and when the user comes back answers the same question with none of them. A change that must show while Companion keeps the focus comes as a game message, not as a watch: a key rebound in the game while Companion is visible beside it comes as `KEY_ASSIGNMENTS`. When any screen closes, the game compares its key bindings with those when a screen closed before, and where they differ, tells Companion once the screen saved `options.txt`, which it does as the screen is removed, after NeoForge's `ScreenEvent.Closing`. The message carries nothing: Companion reads `options.txt` again, which stays the one place it reads keys from. ## 3. Pages diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java index 0cf0b5ac..3cc3dabe 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/TotalDebugClient.java @@ -64,6 +64,8 @@ public final class TotalDebugClient { private volatile boolean snapshotRequested; /** What the game last told Companion it plays, whose world every world-bound request must name. Client thread writes. */ private volatile PlayingPayload playing = new PlayingPayload.Menu(); + /** The key bindings when a screen closed last, as {@code options.txt} saves them; null before. Client thread only. */ + private Map keys; private TotalDebugClient(Path gameDirectory) { Path totalDebugDirectory = gameDirectory @@ -197,6 +199,19 @@ public void packsChanged() { this.packStacks.publish(); } + /** + * A screen closes. Where the key bindings differ from those when a screen closed before, as after the controls screen, + * Companion is told once the screen saved {@code options.txt}: the game saves it as the screen is removed, after this + * event. Client thread only. + */ + public void screenClosing() { + Map assigned = KeyBindingEdits.assigned(); + if (assigned.equals(this.keys)) return; + this.keys = assigned; + // Queued rather than run now, as Minecraft.execute would on this thread. + Minecraft.getInstance().tell(this.companionApp::sendKeyAssignments); + } + /** The player joined a singleplayer world or a server. Client thread only. */ public void joined() { play(Playing.joined()); diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/catalog/KeyBindingEdits.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/catalog/KeyBindingEdits.java index a9182d09..2bad428a 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/catalog/KeyBindingEdits.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/catalog/KeyBindingEdits.java @@ -6,6 +6,8 @@ import net.minecraft.client.Minecraft; import net.neoforged.neoforge.client.settings.KeyModifier; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; /** @@ -19,7 +21,17 @@ public final class KeyBindingEdits implements ChangeTable.Category { @Override public String read(String name) { - KeyMapping mapping = mapping(name); + return value(mapping(name)); + } + + /** Every binding's key, as {@code options.txt} saves it, by the binding's name. */ + public static Map assigned() { + Map assigned = new HashMap<>(); + for (KeyMapping mapping : Minecraft.getInstance().options.keyMappings) assigned.put(mapping.getName(), value(mapping)); + return assigned; + } + + private static String value(KeyMapping mapping) { KeyModifier modifier = mapping.getKeyModifier(); return mapping.getKey().getName() + (modifier == KeyModifier.NONE ? "" : ":" + modifier.name()); } diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java index 452c073f..341c1785 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionAppClient.java @@ -19,6 +19,7 @@ import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeResultMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PackStackMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.KeyAssignmentsMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ReloadMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ReloadResultMessage; @@ -306,6 +307,11 @@ public void sendPlaying(PlayingMessage message) { send(message); } + /** Tells Companion that the game saved changed key bindings in {@code options.txt}. */ + public void sendKeyAssignments() { + send(new KeyAssignmentsMessage()); + } + public void setProgressListener(Consumer listener) { this.progressListener = Objects.requireNonNull(listener, "listener"); } diff --git a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/lifecycle/ClientLifecycleEvents.java b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/lifecycle/ClientLifecycleEvents.java index 61356ab0..b51a892d 100644 --- a/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/lifecycle/ClientLifecycleEvents.java +++ b/mod/src/main/java/com/github/minecraft_ta/totaldebug/client/lifecycle/ClientLifecycleEvents.java @@ -52,11 +52,13 @@ static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { } /** - * The Resource Packs screen rescans the pack folders while it is open. Closing it without changing the selection - * starts no resource reload, so the packs it found are told here. It selected the packs before it closes. + * A screen that changed key bindings, as the game's or a mod's controls screen, has Companion told. The Resource + * Packs screen rescans the pack folders while it is open; closing it without changing the selection starts no + * resource reload, so the packs it found are told here. It selected the packs before it closes. */ @SubscribeEvent static void onScreenClosing(ScreenEvent.Closing event) { + TotalDebugClient.current().ifPresent(TotalDebugClient::screenClosing); if (!(event.getScreen() instanceof PackSelectionScreen)) return; ResourceReloads.selected(); TotalDebugClient.current().ifPresent(TotalDebugClient::packsChanged); diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java index f3ec0c91..ba17224d 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/CompanionProtocol.java @@ -1,7 +1,7 @@ package com.github.minecraft_ta.totaldebug.protocol; public final class CompanionProtocol { - public static final int VERSION = 41; + public static final int VERSION = 42; public static final short READY = 1; // 2 was the game opening a class, which the /decompile command used. @@ -37,6 +37,7 @@ public final class CompanionProtocol { public static final short CHANGE_RESULT = 49; public static final short DATAPACKS = 50; public static final short DATAPACKS_REQUEST = 51; + public static final short KEY_ASSIGNMENTS = 52; private CompanionProtocol() { } diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/KeyAssignmentsMessage.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/KeyAssignmentsMessage.java new file mode 100644 index 00000000..10d21062 --- /dev/null +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/KeyAssignmentsMessage.java @@ -0,0 +1,20 @@ +package com.github.minecraft_ta.totaldebug.protocol.scnet; + +import com.github.tth05.scnet.message.AbstractMessage; +import com.github.tth05.scnet.util.ByteBufferInputStream; +import com.github.tth05.scnet.util.ByteBufferOutputStream; + +/** + * The game tells Companion that its key bindings changed and that {@code options.txt} holds them now, as after the + * player rebound a key in the game's controls screen. Companion reads the file again; the message carries nothing, so + * the file stays the one place the keys are read from. + */ +public final class KeyAssignmentsMessage extends AbstractMessage { + @Override + public void read(ByteBufferInputStream input) { + } + + @Override + public void write(ByteBufferOutputStream output) { + } +} diff --git a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java index be9acbcd..dc164671 100644 --- a/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java +++ b/protocol/src/main/java/com/github/minecraft_ta/totaldebug/protocol/scnet/ProtocolBindings.java @@ -29,6 +29,7 @@ public static void registerMod(IMessageProcessor processor) { processor.registerOutgoing(CompanionProtocol.PREPARED_FILE, PreparedFileMessage.class); processor.registerIncoming(CompanionProtocol.CHANGE, ChangeMessage.class, ChangeMessage::new); processor.registerOutgoing(CompanionProtocol.CHANGE_RESULT, ChangeResultMessage.class); + processor.registerOutgoing(CompanionProtocol.KEY_ASSIGNMENTS, KeyAssignmentsMessage.class); } public static void registerCompanion(IMessageProcessor processor) { @@ -52,5 +53,6 @@ public static void registerCompanion(IMessageProcessor processor) { processor.registerIncoming(CompanionProtocol.PREPARED_FILE, PreparedFileMessage.class, PreparedFileMessage::new); processor.registerOutgoing(CompanionProtocol.CHANGE, ChangeMessage.class); processor.registerIncoming(CompanionProtocol.CHANGE_RESULT, ChangeResultMessage.class, ChangeResultMessage::new); + processor.registerIncoming(CompanionProtocol.KEY_ASSIGNMENTS, KeyAssignmentsMessage.class, KeyAssignmentsMessage::new); } } diff --git a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java index 93b1af19..2514af60 100644 --- a/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java +++ b/protocol/src/test/java/com/github/minecraft_ta/totaldebug/protocol/GoldenMessages.java @@ -4,8 +4,8 @@ public final class GoldenMessages { public static final String RUN_SCRIPT = "0000000700000001580000000100000001580000000301020300000009696e76656e746f72790100000009504f53545f5449434b000000000000000000000000"; public static final String STOP_SCRIPT = "00000007"; - public static final String CLIENT_HELLO = "0000002900000003616263000000017000000001640000000177"; - public static final String SERVER_HELLO = "000000290100000000"; + public static final String CLIENT_HELLO = "0000002a00000003616263000000017000000001640000000177"; + public static final String SERVER_HELLO = "0000002a0100000000"; public static final String PREPARED_FILE = "00000001000000010000000269640000000466696c6500000000"; public static final String DEBUG_TARGET = "0000000269640000000467616d6501000000000000002a";