diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 08c1d36..5f90b2d 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -139,9 +139,18 @@ jobs: uses: actions/checkout@v4 with: repository: FPSMasterTeam/fpsmaster-prism - ref: 1a3b61b2d75b9be6bed583ad08b52fb0016e8b83 + # Prism 0.2.2(fix/prism-review 已合入 main 的提交)。 + ref: e3b6e244f912828bd11d777683c47efd94c79121 path: fpsmaster-prism + - name: Check out Cadence + uses: actions/checkout@v4 + with: + repository: FPSMasterTeam/Cadence + # Cadence v0.1.2。 + ref: 9e60bab7834a73ed4ceead49ecec2d07af708b80 + path: cadence + - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v4 with: @@ -157,6 +166,12 @@ jobs: chmod +x ./gradlew ./gradlew publishToMavenLocal + - name: Install Cadence + working-directory: cadence + run: | + chmod +x ./gradlew + ./gradlew publishToMavenLocal + - name: Build with Gradle shell: bash env: diff --git a/build.gradle.kts b/build.gradle.kts index 04ddab9..d2f32e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -303,11 +303,12 @@ dependencies { } implementation("com.google.code.gson:gson:2.14.0") - // Cadence:网易云/QQ 音乐数据客户端(原 top.fpsmaster.web.music 抽出,JitPack 托管)。 + // Cadence:网易云/QQ 音乐数据客户端(原 top.fpsmaster.web.music 抽出)。0.1.2 还没有 tag/JitPack 版本, + // 先从 mavenLocal 取(在 music-api 仓库 ./gradlew publishToMavenLocal)。 // 纯 JDK + gson 实现,不含 net.minecraft,故无需 Loom remap。 - implementation("com.github.FPSMasterTeam:Cadence:v0.1.1") + implementation("top.fpsmaster:music-api:0.1.2") implementation("com.googlecode.soundlibs:mp3spi:1.9.5.4") - implementation("top.fpsmaster:prism:0.2.1") + implementation("top.fpsmaster:prism:0.2.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.11.0") compileOnly("org.projectlombok:lombok:1.18.46") @@ -322,9 +323,9 @@ dependencies { bundledRuntime("io.github.vlouboos:standaloneevent-common:1.6") // 只打 Cadence 自己的类:它的两个传递依赖 kotlin-stdlib(上面已 bundle)与 gson(MC 自带) // 都已在运行时就位,transitive 打进来只会重复/覆盖。 - bundledRuntime("com.github.FPSMasterTeam:Cadence:v0.1.1") { isTransitive = false } + bundledRuntime("top.fpsmaster:music-api:0.1.2") { isTransitive = false } bundledRuntime("com.googlecode.soundlibs:mp3spi:1.9.5.4") - bundledRuntime("top.fpsmaster:prism:0.2.1") + bundledRuntime("top.fpsmaster:prism:0.2.2") testImplementation(kotlin("test")) } diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinMinecraft.java b/src/main/java/top/fpsmaster/mixin/impl/MixinMinecraft.java index b22d884..e927e5a 100644 --- a/src/main/java/top/fpsmaster/mixin/impl/MixinMinecraft.java +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinMinecraft.java @@ -81,18 +81,23 @@ public abstract class MixinMinecraft implements IMinecraftSession { //?} } + // Two distinct cadences: Client.tick runs once per frame (runTick HEAD, also used to drain deferred + // screen switches), TickEvent fires once per 20 Hz game tick. Minecraft.tick()V has the same + // signature on every Stonecutter target, so the game-tick hook must not be version-gated: modules + // like Sprint/ToggleSneak/Optimization/CosmeticLoadoutCache only ever see TickEvent. @Inject(method = "runTick", at = @At("HEAD")) - private void fpsmaster$tick(boolean renderLevel, CallbackInfo callback) { + private void fpsmaster$frame(boolean renderLevel, CallbackInfo callback) { Client.tick(); } - //? if >=1.21.5 { @Inject(method = "tick", at = @At("HEAD")) - private void fpsmaster$tick(CallbackInfo callback) { + private void fpsmaster$gameTick(CallbackInfo callback) { + if (top.fpsmaster.diagnostics.Smoke.ENABLED) { + top.fpsmaster.diagnostics.Smoke.feature("tick-event"); + } StandaloneEventAPI.getApi().call(new TickEvent()); } - //?} @Inject(method = "startAttack", at = @At("HEAD")) private void fpsmaster$startAttack(CallbackInfoReturnable cir) { StandaloneEventAPI.getApi().call(new MouseEvent(0)); diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinOnlineServerEntry.java b/src/main/java/top/fpsmaster/mixin/impl/MixinOnlineServerEntry.java new file mode 100644 index 0000000..c69593d --- /dev/null +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinOnlineServerEntry.java @@ -0,0 +1,38 @@ +package top.fpsmaster.mixin.impl; + +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.ServerList; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; +import top.fpsmaster.multiplayer.ServerBrowser; + +import java.util.ArrayList; +import java.util.List; + +/** + * Vanilla's move arrows / Shift+Up/Down compute positions with {@code children().indexOf(this)}, + * i.e. in display order, and hand them straight to {@code ServerList.swap}. Once + * {@link MixinServerSelectionList} inserts promoted rows and lifts pinned ones, display positions + * no longer match servers.dat: moving the last row up threw IndexOutOfBoundsException, other moves + * reordered the wrong servers. Translate both positions back to servers.dat and skip any swap that + * involves a promoted row, which is not in servers.dat at all. + */ +@Mixin(targets = "net.minecraft.client.gui.screens.multiplayer.ServerSelectionList$OnlineServerEntry") +public class MixinOnlineServerEntry { + @Redirect( + method = "swap(II)V", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ServerList;swap(II)V") + ) + private void fpsmaster$swapStoredPositions(ServerList list, int displayFrom, int displayTo) { + List stored = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + stored.add(list.get(i)); + } + int from = ServerBrowser.INSTANCE.storedIndex(stored, displayFrom); + int to = ServerBrowser.INSTANCE.storedIndex(stored, displayTo); + if (from >= 0 && to >= 0) { + list.swap(from, to); + } + } +} diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinReplayClientTick.java b/src/main/java/top/fpsmaster/mixin/impl/MixinReplayClientTick.java index 1034bf2..1f0b9e3 100644 --- a/src/main/java/top/fpsmaster/mixin/impl/MixinReplayClientTick.java +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinReplayClientTick.java @@ -11,8 +11,8 @@ * Drives the parts of a replay that belong on the client thread: sampling the recording player's own * position and equipment, which no clientbound packet carries, and advancing playback. * - * Hooked on {@code Minecraft.tick} rather than through the event bus because the bus only dispatches - * a client tick from 1.21.5 onwards, and a recording has to sample on every version. + * Hooked on {@code Minecraft.tick} directly rather than through the event bus so that a failing + * recording is isolated here and never reaches the other TickEvent subscribers. */ @Mixin(Minecraft.class) public class MixinReplayClientTick { diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinReplayPacketCapture.java b/src/main/java/top/fpsmaster/mixin/impl/MixinReplayPacketCapture.java index df8bcf2..b12d30d 100644 --- a/src/main/java/top/fpsmaster/mixin/impl/MixinReplayPacketCapture.java +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinReplayPacketCapture.java @@ -36,7 +36,7 @@ public class MixinReplayPacketCapture { top.fpsmaster.diagnostics.Smoke.mixin("replay-packet"); top.fpsmaster.diagnostics.Smoke.feature("replay"); } - Replay.onClientboundPacket(packet); + Replay.onClientboundPacket(packet, ((Connection) (Object) this).getPacketListener()); } catch (Throwable failure) { // A recording is never worth dropping a connection over. } diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java index 18a27e4..43585af 100644 --- a/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinServerSelectionList.java @@ -51,6 +51,7 @@ public class MixinServerSelectionList { userServers, data -> data.ip, MixinServerSelectionList::fpsmaster$promotedServerData); + ServerBrowser.INSTANCE.rememberDisplayed(fpsmaster$arranged); } @Redirect( diff --git a/src/main/java/top/fpsmaster/musicui/AudioEngine.java b/src/main/java/top/fpsmaster/musicui/AudioEngine.java index e586fd3..393694e 100644 --- a/src/main/java/top/fpsmaster/musicui/AudioEngine.java +++ b/src/main/java/top/fpsmaster/musicui/AudioEngine.java @@ -17,6 +17,7 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.util.concurrent.atomic.AtomicInteger; /** * 纯 Java 音频播放引擎,基于 {@code javax.sound.sampled} + mp3spi(JLayer)。 @@ -26,6 +27,11 @@ * *

解码与播放在后台线程进行,绝不阻塞渲染线程;曲目自然播完时通过 * {@link Minecraft#execute(Runnable)} 在主线程回调 {@code onEnded}。 + * + *

每次 play/stop 都推进 {@link #generation},播放线程只认自己那一代:interrupt 打断不了 + * HttpURLConnection 的阻塞读和 SourceDataLine.write,旧线程可能在新曲目开始后才醒来, + * 若还看共享的 stopped 标志(已被新曲目置回 false)就会和新曲目叠着放、互相改进度。 + * 所以 stop 不再在调用方线程 join 旧线程,旧线程醒来发现过期就自己收尾退出。 */ public class AudioEngine { @@ -36,6 +42,7 @@ public class AudioEngine { + "Chrome/123.0.0.0 Safari/537.36"; private volatile Thread playThread; + private final AtomicInteger generation = new AtomicInteger(); private volatile boolean stopped = true; private volatile boolean paused = false; @@ -82,24 +89,20 @@ public float getVolume() { * @param onEnded 曲目自然播完时在主线程回调;可为 null。 */ public void play(final String url, final String referer, long knownDurationMs, final Runnable onEnded) { - stop(); - synchronized (lock) { - stopped = false; - paused = false; - positionMs = 0; - durationMs = knownDurationMs; - pendingSeekMs = -1; - } + final int gen = begin(knownDurationMs); final Thread t = new Thread(new Runnable() { @Override public void run() { File temp = null; try { - temp = downloadToTemp(url, referer); + temp = downloadToTemp(url, referer, gen); + if (!isCurrent(gen)) { + return; + } synchronized (lock) { currentTemp = temp; } - playLoop(temp, onEnded); + playLoop(temp, onEnded, gen); } catch (Throwable e) { LOGGER.error("Music playback failed: " + e.getMessage()); } finally { @@ -118,19 +121,12 @@ public void run() { } public void playFile(final File file, long knownDurationMs, final Runnable onEnded) { - stop(); - synchronized (lock) { - stopped = false; - paused = false; - positionMs = 0; - durationMs = knownDurationMs; - pendingSeekMs = -1; - } + final int gen = begin(knownDurationMs); final Thread t = new Thread(new Runnable() { @Override public void run() { try { - playLoop(file, onEnded); + playLoop(file, onEnded, gen); } catch (Throwable e) { LOGGER.error("Local music playback failed: " + e.getMessage()); } @@ -143,11 +139,28 @@ public void run() { t.start(); } - private void playLoop(File file, Runnable onEnded) throws Exception { + /** Stops whatever is playing and starts a new session; returns its generation. */ + private int begin(long knownDurationMs) { + stop(); + synchronized (lock) { + stopped = false; + paused = false; + positionMs = 0; + durationMs = knownDurationMs; + pendingSeekMs = -1; + return generation.get(); + } + } + + private boolean isCurrent(int gen) { + return !stopped && generation.get() == gen; + } + + private void playLoop(File file, Runnable onEnded, int gen) throws Exception { long startMs = 0; boolean endedNaturally = false; - while (!stopped) { + while (isCurrent(gen)) { AudioInputStream fileIn = null; AudioInputStream din = null; SourceDataLine line = null; @@ -155,7 +168,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception { try { fileIn = AudioSystem.getAudioInputStream(new BufferedInputStream(new FileInputStream(file))); AudioFormat base = fileIn.getFormat(); - if (durationMs <= 0 && fileIn.getFrameLength() > 0 && base.getFrameRate() > 0) { + if (isCurrent(gen) && durationMs <= 0 && fileIn.getFrameLength() > 0 && base.getFrameRate() > 0) { durationMs = (long) (fileIn.getFrameLength() * 1000.0 / base.getFrameRate()); } AudioFormat decoded = new AudioFormat( @@ -182,14 +195,14 @@ private void playLoop(File file, Runnable onEnded) throws Exception { // not others, and why seeking again "fixed" it. long skipBytes = (long) (startMs * bytesPerMs); skipBytes -= skipBytes % frameSize; - skipDecoded(din, skipBytes); + skipDecoded(din, skipBytes, gen); } long posBytes = 0; byte[] buf = new byte[4096]; // Bytes left over from the previous read that do not yet complete a frame. int carry = 0; - while (!stopped) { + while (isCurrent(gen)) { long seek = pendingSeekMs; if (seek >= 0) { pendingSeekMs = -1; @@ -220,7 +233,9 @@ private void playLoop(File file, Runnable onEnded) throws Exception { if (writable > 0) { line.write(buf, 0, writable); posBytes += writable; - positionMs = startMs + (long) (posBytes / bytesPerMs); + if (isCurrent(gen)) { + positionMs = startMs + (long) (posBytes / bytesPerMs); + } } carry = available - writable; if (carry > 0) { @@ -231,7 +246,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception { closeQuietly(line, din, fileIn); } - if (stopped) { + if (!isCurrent(gen)) { return; } if (seekBreak) { @@ -242,7 +257,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception { } } - if (endedNaturally && !stopped && onEnded != null) { + if (endedNaturally && isCurrent(gen) && onEnded != null) { stopped = true; Minecraft mc = Minecraft.getInstance(); if (mc != null) { @@ -252,10 +267,10 @@ private void playLoop(File file, Runnable onEnded) throws Exception { } /** 逐块读丢弃到目标字节数(mp3spi 的 skip 不可靠,故用读丢弃)。 */ - private void skipDecoded(AudioInputStream din, long bytes) throws Exception { + private void skipDecoded(AudioInputStream din, long bytes, int gen) throws Exception { byte[] junk = new byte[4096]; long remaining = bytes; - while (remaining > 0 && !stopped) { + while (remaining > 0 && isCurrent(gen)) { int toRead = (int) Math.min(junk.length, remaining); int n = din.read(junk, 0, toRead); if (n < 0) break; @@ -326,28 +341,24 @@ public void seek(long ms) { paused = false; } - /** 停止并释放当前曲目。 */ + /** 停止当前曲目。不等待旧线程:它醒来发现代际已过期会自己关掉 line 和临时文件。 */ public void stop() { Thread t; synchronized (lock) { stopped = true; paused = false; + generation.incrementAndGet(); t = playThread; playThread = null; + positionMs = 0; + gainControl = null; } if (t != null) { t.interrupt(); - try { - t.join(500); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } } - positionMs = 0; - gainControl = null; } - private File downloadToTemp(String url, String referer) throws Exception { + private File downloadToTemp(String url, String referer, int gen) throws Exception { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setInstanceFollowRedirects(true); conn.setConnectTimeout(10_000); @@ -366,7 +377,7 @@ private File downloadToTemp(String url, String referer) throws Exception { byte[] buf = new byte[16384]; int n; while ((n = in.read(buf)) != -1) { - if (stopped) break; + if (!isCurrent(gen)) break; out.write(buf, 0, n); } } finally { diff --git a/src/main/java/top/fpsmaster/ui/kit/NovaBlurTexture.java b/src/main/java/top/fpsmaster/ui/kit/NovaBlurTexture.java index f5ed2c4..2bd02a6 100644 --- a/src/main/java/top/fpsmaster/ui/kit/NovaBlurTexture.java +++ b/src/main/java/top/fpsmaster/ui/kit/NovaBlurTexture.java @@ -16,13 +16,10 @@ public final class NovaBlurTexture extends AbstractTexture { public void attach(RenderTarget target) { this.texture = target.getColorTexture(); this.textureView = target.getColorTextureView(); - this.sampler = com.mojang.blaze3d.systems.RenderSystem.getDevice().createSampler( - com.mojang.blaze3d.textures.AddressMode.CLAMP_TO_EDGE, - com.mojang.blaze3d.textures.AddressMode.CLAMP_TO_EDGE, - com.mojang.blaze3d.textures.FilterMode.LINEAR, - com.mojang.blaze3d.textures.FilterMode.LINEAR, - 0, - java.util.OptionalDouble.empty()); + // The shared cached sampler: attach() runs on every framebuffer resize (every frame while + // dragging a window edge), and a sampler created here was never closed. + this.sampler = com.mojang.blaze3d.systems.RenderSystem.getSamplerCache() + .getClampToEdge(com.mojang.blaze3d.textures.FilterMode.LINEAR); } @Override diff --git a/src/main/kotlin/top/fpsmaster/Client.kt b/src/main/kotlin/top/fpsmaster/Client.kt index 16fe905..b74111b 100644 --- a/src/main/kotlin/top/fpsmaster/Client.kt +++ b/src/main/kotlin/top/fpsmaster/Client.kt @@ -162,7 +162,8 @@ class Client : ModInitializer { } private fun onTick() { - if (Minecraft.getInstance().screenCompat == null) { + // 0 = unbound; glfwGetKey(window, 0) raises GLFW_INVALID_ENUM every frame. + if (Minecraft.getInstance().screenCompat == null && ClientSettings.clickGuiKey.isBound()) { //? if >=1.21.11 { if (GLFW.glfwGetKey(Minecraft.getInstance().window.handle(), ClientSettings.clickGuiKey.getValue()) == GLFW.GLFW_PRESS) { //?} else { @@ -213,6 +214,7 @@ class Client : ModInitializer { top.fpsmaster.ui.kit.DeferredUi.drain() val client = INSTANCE ?: return client.onTick() + showStartupNotice() TelemetryReporter.tick(System.currentTimeMillis()) RuntimeProbe.frame() } @@ -228,6 +230,8 @@ class Client : ModInitializer { return } top.fpsmaster.ui.kit.NovaBlur.release() + // The writer is a daemon thread: wait for it here or the recording loses its tail. + top.fpsmaster.replay.ReplayRecorder.stop(wait = true) TelemetryReporter.shutdown() MusicController.shutdown() top.fpsmaster.cosmetic.CosmeticLoadoutClient.shutdown() @@ -239,9 +243,34 @@ class Client : ModInitializer { } catch (throwable: Throwable) { logger.warn("Failed to shut down CEF", throwable) } + // Profile writes are queued off the render thread; make sure the last one lands. + ConfigManager.awaitWrites() RuntimeProbe.report("shutdown") } + /** + * "Config restored from backup" is decided in onInitialize, before languages load and long + * before anything can be shown; it waits for the first screen after the loading overlay. + */ + private fun showStartupNotice() { + val mc = Minecraft.getInstance() + //? if >=26 { + /*val loading = mc.gui.overlay() != null + *///?} else { + val loading = mc.overlay != null + //?} + if (loading || mc.screenCompat == null) { + return + } + val (key, argument) = ConfigManager.takeStartupNotice() ?: return + top.fpsmaster.notification.NotificationManager.add( + top.fpsmaster.translation.Language.get("configbackup.title"), + top.fpsmaster.translation.Language.get(key).format(argument), + 8f, + top.fpsmaster.prism.overlay.NotificationCenter.Type.WARNING + ) + } + @JvmStatic fun openClickGui() { Minecraft.getInstance().setScreenCompat(top.fpsmaster.ui.NativeClickGuiScreen()) diff --git a/src/main/kotlin/top/fpsmaster/Util.kt b/src/main/kotlin/top/fpsmaster/Util.kt index 5b9cfbf..3dcdf24 100644 --- a/src/main/kotlin/top/fpsmaster/Util.kt +++ b/src/main/kotlin/top/fpsmaster/Util.kt @@ -109,6 +109,13 @@ fun Minecraft.setScreenCompat(screen: net.minecraft.client.gui.screens.Screen?) val Minecraft.screenCompat: net.minecraft.client.gui.screens.Screen? get() = this.screen //?} +// GLFW window handle: Window.window became Window.handle() in 1.21.11. +//? if >=1.21.11 { +fun windowHandleCompat(): Long = mc.window.handle() +//?} else { +/*fun windowHandleCompat(): Long = mc.window.window +*///?} + // MC 26.2 moved the F1 state to Hud.isHidden(); older versions keep Options.hideGui. //? if >=26 { /*val hideGuiCompat: Boolean get() = mc.gui.hud.isHidden*/ diff --git a/src/main/kotlin/top/fpsmaster/account/AccountManager.kt b/src/main/kotlin/top/fpsmaster/account/AccountManager.kt index 2966859..07131f1 100644 --- a/src/main/kotlin/top/fpsmaster/account/AccountManager.kt +++ b/src/main/kotlin/top/fpsmaster/account/AccountManager.kt @@ -14,6 +14,7 @@ import top.fpsmaster.mixin.interfaces.IMinecraftSession import java.net.Proxy import java.nio.charset.StandardCharsets import java.nio.file.Files +import top.fpsmaster.config.AtomicFiles import java.util.Optional import java.util.UUID import java.util.concurrent.Executors @@ -135,6 +136,9 @@ class AccountManager private constructor() { val stored = findStored(account) ?: return val wasCurrent = namesEqual(stored.name, currentName()) accounts.remove(stored) + if (stored.microsoft()) { + removedSecrets.add(MicrosoftAuth.dashedUuid(stored.uuid)) + } if (wasCurrent) { selectedName = null if (launcherUser != null) { @@ -223,6 +227,9 @@ class AccountManager private constructor() { } private fun applyUser(user: User, service: UserApiService?) { + // The cosmetics link proves *this* Minecraft account to the backend. Keeping the old state + // would leave a new account OFFLINE (or linked as the previous one) for the whole session. + top.fpsmaster.auth.MinecraftLinkClient.clear() val session = Minecraft.getInstance() as IMinecraftSession session.`fpsmaster$setUser`(user) if (service != null) { @@ -267,31 +274,93 @@ class AccountManager private constructor() { private fun file() = mc.gameDirectory.toPath().resolve("fpsmaster").resolve("accounts.json") + /** A second copy of accounts.json, so a damaged file does not lose every account. */ + private fun backupFile() = file().resolveSibling("accounts.json.bak") + + /** + * Microsoft access/refresh tokens live outside the game directory, next to the FPSMaster auth + * file (%APPDATA%/FPSMaster or ~/.fpsmaster), keyed by account UUID. Players zip and share whole + * instance folders; a refresh token in there is a long-lived key to their Minecraft account. + */ + private fun secretsFile() = top.fpsmaster.auth.AuthService.authFilePath().resolveSibling(SECRETS_FILE_NAME) + + /** UUIDs of Microsoft accounts removed this session, whose tokens must go from [secretsFile]. */ + private val removedSecrets = HashSet() + private fun load() { val path = file() if (!Files.isRegularFile(path)) { return } - try { - val root = JsonParser.parseString(Files.readString(path)) - if (root == null || root.isJsonNull) { + val inlineTokens = try { + parse(Files.readString(path)) + } catch (exception: Exception) { + logger.warn("Failed to load accounts.json, trying accounts.json.bak: $exception") + accounts.clear() + try { + parse(Files.readString(backupFile())) + } catch (backupFailure: Exception) { + logger.warn("accounts.json.bak is unusable too: $backupFailure") + accounts.clear() return } - if (root.isJsonArray) { - readAccounts(root.asJsonArray) - return + } + mergeSecrets() + // Files written before tokens moved out still carry them inline: rewrite once without. + if (inlineTokens) { + save() + } + } + + /** Returns whether any account still had its tokens stored inline. */ + private fun parse(text: String): Boolean { + val root = JsonParser.parseString(text) + if (root == null || root.isJsonNull) { + return false + } + if (root.isJsonArray) { + readAccounts(root.asJsonArray) + } else if (root.isJsonObject) { + val obj = root.asJsonObject + if (obj.has("selected") && !obj.get("selected").isJsonNull) { + selectedName = obj.get("selected").asString } - if (root.isJsonObject) { - val obj = root.asJsonObject - if (obj.has("selected") && !obj.get("selected").isJsonNull) { - selectedName = obj.get("selected").asString - } - if (obj.has("accounts") && obj.get("accounts").isJsonArray) { - readAccounts(obj.getAsJsonArray("accounts")) - } + if (obj.has("accounts") && obj.get("accounts").isJsonArray) { + readAccounts(obj.getAsJsonArray("accounts")) } + } else { + throw IllegalStateException("accounts.json is neither an object nor an array") + } + return accounts.any { !it.accessToken.isNullOrEmpty() || !it.refreshToken.isNullOrEmpty() } + } + + private fun readSecrets(): JsonObject { + val path = secretsFile() + if (!Files.isRegularFile(path)) { + return JsonObject() + } + return try { + JsonParser.parseString(Files.readString(path)).takeIf { it.isJsonObject }?.asJsonObject ?: JsonObject() } catch (exception: Exception) { - logger.warn("Failed to load accounts.json: $exception") + logger.warn("Failed to read {}: {}", SECRETS_FILE_NAME, exception.toString()) + JsonObject() + } + } + + private fun mergeSecrets() { + val secrets = readSecrets() + for (account in accounts) { + if (!account.microsoft()) { + continue + } + val entry = secrets.get(MicrosoftAuth.dashedUuid(account.uuid))?.takeIf { it.isJsonObject }?.asJsonObject + ?: continue + if (account.accessToken.isNullOrEmpty()) { + account.accessToken = entry.get("accessToken")?.takeUnless { it.isJsonNull }?.asString + } + if (account.refreshToken.isNullOrEmpty()) { + account.refreshToken = entry.get("refreshToken")?.takeUnless { it.isJsonNull }?.asString + } } } @@ -321,13 +390,37 @@ class AccountManager private constructor() { private fun save() { try { - Files.createDirectories(file().parent) val root = JsonObject() if (selectedName != null) { root.addProperty("selected", selectedName) } - root.add("accounts", GSON.toJsonTree(accounts)) - Files.writeString(file(), GSON.toJson(root)) + val list = JsonArray() + accounts.forEach { account -> + val json = GSON.toJsonTree(account).asJsonObject + json.remove("accessToken") + json.remove("refreshToken") + list.add(json) + } + root.add("accounts", list) + + // Secrets first: if the process dies between the two writes, the tokens are already safe + // in the user directory and the instance file still has its old (possibly inline) copy. + val secrets = readSecrets() + removedSecrets.forEach { secrets.remove(it) } + accounts.filter { it.microsoft() }.forEach { account -> + secrets.add(MicrosoftAuth.dashedUuid(account.uuid), JsonObject().apply { + addProperty("accessToken", account.accessToken) + addProperty("refreshToken", account.refreshToken) + }) + } + AtomicFiles.write(secretsFile(), GSON.toJson(secrets), ownerOnly = true) + + // The .bak is the same last-known-good content, written by us: a hand edit or damage + // to accounts.json later still leaves this copy. (Copying the old file instead would + // carry legacy inline tokens into the .bak.) + val text = GSON.toJson(root) + AtomicFiles.write(file(), text) + AtomicFiles.write(backupFile(), text) } catch (exception: Exception) { logger.warn("Failed to save accounts.json: $exception") } @@ -337,6 +430,7 @@ class AccountManager private constructor() { const val TYPE_OFFLINE = "offline" const val TYPE_MICROSOFT = "microsoft" const val LAUNCHER_ID = "__launcher__" + private const val SECRETS_FILE_NAME = "nova-accounts.json" private val GSON = GsonBuilder().setPrettyPrinting().create() private val IO = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "FPSMaster-Account").apply { isDaemon = true } diff --git a/src/main/kotlin/top/fpsmaster/auth/AuthService.kt b/src/main/kotlin/top/fpsmaster/auth/AuthService.kt index 2cca234..6267f3d 100644 --- a/src/main/kotlin/top/fpsmaster/auth/AuthService.kt +++ b/src/main/kotlin/top/fpsmaster/auth/AuthService.kt @@ -15,6 +15,7 @@ import kotlin.io.path.writer object AuthService { private const val AUTH_FILE_NAME = "auth.json" + private const val ENV_LAUNCH_TOKEN = "FPSMASTER_LAUNCH_TOKEN" private const val SYSTEM_PROPERTY_TOKEN = "fpsmaster.auth.token" private const val SYSTEM_PROPERTY_REFRESH_TOKEN = "fpsmaster.auth.refreshToken" private const val SYSTEM_PROPERTY_TOKEN_EXPIRES_AT = "fpsmaster.auth.tokenExpiresAt" @@ -43,11 +44,11 @@ object AuthService { private val fileLock = Any() fun initialize() { - val launcherToken = System.getProperty(SYSTEM_PROPERTY_TOKEN) - if (!launcherToken.isNullOrBlank()) { - accessToken = launcherToken - refreshToken = System.getProperty(SYSTEM_PROPERTY_REFRESH_TOKEN) - tokenExpiresAt = parseExpiresAt(System.getProperty(SYSTEM_PROPERTY_TOKEN_EXPIRES_AT)) + val launch = takeLauncherCredentials(System.getenv(ENV_LAUNCH_TOKEN)) + if (launch != null) { + accessToken = launch.token + refreshToken = launch.refreshToken + tokenExpiresAt = parseExpiresAt(launch.expiresAt) save() logger.info("Loaded FPSMaster auth token from launcher") return @@ -56,6 +57,22 @@ object AuthService { load() } + internal class LaunchCredentials(val token: String, val refreshToken: String?, val expiresAt: String?) + + /** + * The launcher hands the platform token over in [ENV_LAUNCH_TOKEN]; the `-D` property is the + * older channel, still sent for builds that predate the variable. Either way the properties are + * removed right away: anything that dumps system properties later (crash reports, diagnostics, + * other mods) must not find the token there. + */ + internal fun takeLauncherCredentials(envToken: String?): LaunchCredentials? { + val propertyToken = System.clearProperty(SYSTEM_PROPERTY_TOKEN) + val refresh = System.clearProperty(SYSTEM_PROPERTY_REFRESH_TOKEN) + val expiresAt = System.clearProperty(SYSTEM_PROPERTY_TOKEN_EXPIRES_AT) + val token = envToken?.takeIf { it.isNotBlank() } ?: propertyToken?.takeIf { it.isNotBlank() } ?: return null + return LaunchCredentials(token, refresh?.takeIf { it.isNotBlank() }, expiresAt) + } + fun isLoggedIn(): Boolean { return !accessToken.isNullOrBlank() && !isTokenExpired() } diff --git a/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt b/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt index e95b19d..6bfb3b1 100644 --- a/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt +++ b/src/main/kotlin/top/fpsmaster/auth/FPSMasterApiClient.kt @@ -156,7 +156,7 @@ object FPSMasterApiClient { } ApiResult(true, result.message, info, result.statusCode, result.serverMessage) } else { - ApiResult(false, result.message, null, result.statusCode, result.serverMessage) + ApiResult(false, result.message, null, result.statusCode, result.serverMessage, result.code) } } } @@ -477,6 +477,9 @@ object FPSMasterApiClient { .timeout(Duration.ofSeconds(20)) .header("User-Agent", USER_AGENT) .header("Accept", "application/json") + // The backend localizes failure messages by this header; without it they stay in the + // legacy English wording (kept for released clients). + .header("Accept-Language", acceptLanguage()) if (authenticated) { val token = AuthService.accessToken @@ -488,6 +491,9 @@ object FPSMasterApiClient { return builder } + private fun acceptLanguage(): String = + if (top.fpsmaster.module.impl.auxiliary.ClientSettings.language.isSelected("chinese")) "zh-CN" else "en" + private fun parseResponse(response: HttpResponse, dataClass: Class): ApiResult { val status = response.statusCode() val body = response.body() @@ -511,7 +517,8 @@ object FPSMasterApiClient { // 「后端按契约给的原话」得连信封一起认:`success` 字段在才算契约响应。 // Cloudflare 之类挡在前面时正文可能是一段带 message 的 JSON,只看 message // 非空就会把它当成封禁原因贴到界面上。 - ApiResult(success, message, data, status, rawMessage.isNotBlank() && json.has("success")) + val code = json.stringOrBlank("code").takeIf { it.isNotBlank() && !success } + ApiResult(success, message, data, status, rawMessage.isNotBlank() && json.has("success"), code) }.getOrElse { exception -> logger.error("Failed to parse FPSMaster API response", exception) ApiResult(false, "Parse error: ${exception.message}", null, status) @@ -542,8 +549,17 @@ data class ApiResult( * 就是一句英文技术黑话。UI 想「优先显示后端原话」时必须先问这个,否则 Cloudflare * 挡下来的 403 会被当成封禁原因显示出去。 */ - val serverMessage: Boolean = false -) + val serverMessage: Boolean = false, + /** + * Stable error identifier from the backend (`ITEM_ALREADY_OWNED`, `RATE_LIMITED`, ...); null on + * success and when the response was not the backend's envelope. Branch on this, never on + * [message], which is localized by Accept-Language. + */ + val code: String? = null +) { + /** The backend's own localized message, when there is one to show as is. */ + fun serverText(): String? = message.takeIf { serverMessage && code != null && it.isNotBlank() } +} data class LoginResponse( val token: String? = null, diff --git a/src/main/kotlin/top/fpsmaster/auth/MinecraftLinkClient.kt b/src/main/kotlin/top/fpsmaster/auth/MinecraftLinkClient.kt index c13bfb5..a3ec39a 100644 --- a/src/main/kotlin/top/fpsmaster/auth/MinecraftLinkClient.kt +++ b/src/main/kotlin/top/fpsmaster/auth/MinecraftLinkClient.kt @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets import java.util.Locale import java.util.UUID import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicInteger /** * Proves to the backend which Minecraft account this client is signed into, so other players can be @@ -24,6 +25,8 @@ import java.util.concurrent.CompletableFuture */ object MinecraftLinkClient { private const val JOIN_URL = "https://sessionserver.mojang.com/session/minecraft/join" + private const val FIRST_BACKOFF_MS = 30_000L + private const val MAX_BACKOFF_MS = 10 * 60_000L enum class State { /** Not attempted yet. */ @@ -57,15 +60,34 @@ object MinecraftLinkClient { @Volatile private var inFlight = false + /** After a FAILED attempt: no new attempt before this, and how long the last wait was. */ + @Volatile + private var retryAt = 0L + + @Volatile + private var backoffMs = 0L + /** - * Link the current Minecraft session once per client session. Repeated calls are cheap no-ops - * while a link is in flight, already established, or known to be impossible (offline session). + * Bumped by [clear] (sign-out / account switch). A link attempt captures it at start and drops its + * result if it changed, so a late answer for the previous account cannot mark the new one LINKED + * to the old UUID, or overwrite its state with a stale failure. + */ + private val generation = AtomicInteger() + + private enum class JoinOutcome { ACCEPTED, REJECTED, UNAVAILABLE } + + /** + * Link the current Minecraft session. Cheap enough to call every tick: a no-op while a link is in + * flight, established, known to be impossible (offline session), or backing off after a failure + * (30 s, doubling to 10 min). */ fun ensureLinked(): CompletableFuture { if (!AuthService.isLoggedIn()) { return CompletableFuture.completedFuture(false) } - if (inFlight || state == State.LINKED || state == State.OFFLINE) { + if (inFlight || state == State.LINKED || state == State.OFFLINE || + (state == State.FAILED && System.currentTimeMillis() < retryAt) + ) { return CompletableFuture.completedFuture(state == State.LINKED) } return link() @@ -85,58 +107,91 @@ object MinecraftLinkClient { inFlight = true state = State.LINKING + val attempt = generation.get() return FPSMasterApiClient.createMinecraftLinkChallenge() .thenCompose { result -> val challenge = result.data if (!result.success || challenge == null || challenge.serverId.isBlank()) { - fail(result.message) + fail(attempt, result.code ?: "HTTP ${result.statusCode}") return@thenCompose CompletableFuture.completedFuture(false) } CompletableFuture .supplyAsync { joinServer(session, challenge.serverId) } .thenCompose { joined -> - if (!joined) { - state = State.OFFLINE - lastError = "Mojang session rejected the join request" - inFlight = false - logger.info("Minecraft account link skipped: session is not a verified online account") + if (joined == JoinOutcome.UNAVAILABLE) { + // Network trouble or a Mojang outage says nothing about the account. + fail(attempt, "Mojang session server unavailable") + return@thenCompose CompletableFuture.completedFuture(false) + } + if (joined == JoinOutcome.REJECTED) { + ifCurrent(attempt) { + state = State.OFFLINE + lastError = "Mojang session rejected the join request" + inFlight = false + logger.info("Minecraft account link skipped: session is not a verified online account") + } return@thenCompose CompletableFuture.completedFuture(false) } FPSMasterApiClient.confirmMinecraftLink(challenge.challengeId, session.username) .thenApply { confirmation -> val profile = confirmation.data if (confirmation.success && profile != null && profile.minecraftUuid.isNotBlank()) { - linkedUuid = parseUuid(profile.minecraftUuid) - state = State.LINKED - lastError = "" - inFlight = false - logger.info("Minecraft account linked for cosmetics visibility") - true + ifCurrent(attempt) { + linkedUuid = parseUuid(profile.minecraftUuid) + state = State.LINKED + lastError = "" + backoffMs = 0L + inFlight = false + logger.info("Minecraft account linked for cosmetics visibility") + } } else { - fail(confirmation.message) + fail(attempt, confirmation.code ?: "HTTP ${confirmation.statusCode}") false } } } } .exceptionally { exception -> - fail(exception.message ?: exception.javaClass.simpleName) + fail(attempt, exception.message ?: exception.javaClass.simpleName) false } } + /** Signing out, or switching to another Minecraft account: the next world links afresh. */ + @Synchronized fun clear() { + generation.incrementAndGet() state = State.IDLE linkedUuid = null lastError = "" inFlight = false + retryAt = 0L + backoffMs = 0L } - private fun fail(message: String) { - state = State.FAILED - lastError = message - inFlight = false - logger.warn("Minecraft account link failed: {}", message) + private fun fail(attempt: Int, reason: String) { + ifCurrent(attempt) { + backoffMs = if (backoffMs == 0L) FIRST_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + retryAt = System.currentTimeMillis() + backoffMs + state = State.FAILED + lastError = reason + inFlight = false + logger.warn("Minecraft account link failed: {}; retrying in {}s", reason, backoffMs / 1000) + } + } + + /** + * Runs [update] only if no [clear] happened since [attempt] started; returns whether it ran. + * Synchronized with [clear] so the check and the writes cannot interleave with an account switch. + */ + @Synchronized + private fun ifCurrent(attempt: Int, update: () -> Unit): Boolean { + if (generation.get() != attempt) { + logger.info("Dropping Minecraft account link result for a previous session") + return false + } + update() + return true } private fun currentSession(): Session? { @@ -149,8 +204,12 @@ object MinecraftLinkClient { return Session(token, profileId, user.name) } - /** Mojang `joinServer`; 204 means the session server accepted the (session, serverId) pair. */ - private fun joinServer(session: Session, serverId: String): Boolean { + /** + * Mojang `joinServer`; 204 means the session server accepted the (session, serverId) pair. Only + * 401/403 mean the session itself is not an online account; anything else (no response, 429, + * 5xx) is worth trying again later. + */ + private fun joinServer(session: Session, serverId: String): JoinOutcome { val payload = JsonObject().apply { addProperty("accessToken", session.accessToken) addProperty("selectedProfile", undashed(session.profileId)) @@ -165,12 +224,15 @@ object MinecraftLinkClient { connection.setRequestProperty("Content-Type", "application/json") connection.setRequestProperty("User-Agent", "FPSMaster-Nova/${top.fpsmaster.Client.VERSION}") connection.outputStream.use { it.write(payload.toString().toByteArray(StandardCharsets.UTF_8)) } - connection.responseCode == HttpURLConnection.HTTP_NO_CONTENT || - connection.responseCode == HttpURLConnection.HTTP_OK + when (connection.responseCode) { + HttpURLConnection.HTTP_NO_CONTENT, HttpURLConnection.HTTP_OK -> JoinOutcome.ACCEPTED + HttpURLConnection.HTTP_UNAUTHORIZED, HttpURLConnection.HTTP_FORBIDDEN -> JoinOutcome.REJECTED + else -> JoinOutcome.UNAVAILABLE + } } catch (exception: Exception) { // Never include the payload here: it carries the session access token. logger.warn("Mojang joinServer request failed: {}", exception.javaClass.simpleName) - false + JoinOutcome.UNAVAILABLE } finally { connection.disconnect() } diff --git a/src/main/kotlin/top/fpsmaster/chat/ChatAvatarResolver.kt b/src/main/kotlin/top/fpsmaster/chat/ChatAvatarResolver.kt index ff9973f..be3b03a 100644 --- a/src/main/kotlin/top/fpsmaster/chat/ChatAvatarResolver.kt +++ b/src/main/kotlin/top/fpsmaster/chat/ChatAvatarResolver.kt @@ -74,7 +74,27 @@ object ChatAvatarResolver { return name } + /** + * Per rendered chat line. The chat hands the same FormattedCharSequence object back every frame + * for as long as the line exists, so the answer is cached on that object (weakly: the line is + * dropped with the chat history) instead of rebuilding and re-scanning its text per frame. + * A miss is only kept for [SENDER_TTL_MILLIS] — the sender may join the tab list later. + */ + private val lineSenders = java.util.WeakHashMap() + fun senderOf(sequence: net.minecraft.util.FormattedCharSequence): String? { + val now = System.currentTimeMillis() + lineSenders[sequence]?.let { cached -> + if (cached.name != null || cached.expireAt > now) { + return cached.name + } + } + val name = resolveLine(sequence) + lineSenders[sequence] = Sender(name, now + SENDER_TTL_MILLIS) + return name + } + + private fun resolveLine(sequence: net.minecraft.util.FormattedCharSequence): String? { val text = StringBuilder() var whisper: String? = null sequence.accept { _, style, codePoint -> @@ -104,12 +124,26 @@ object ChatAvatarResolver { return name } - fun playerInfo(name: String): PlayerInfo? = mc.connection?.onlinePlayers?.firstOrNull { - it.profile?.name.equals(name, ignoreCase = true) + private var playersByName: Map = emptyMap() + private var playersByNameExpireAt = 0L + + /** Asked per chat line per frame: look up in a name index rebuilt at most every 500 ms. */ + fun playerInfo(name: String): PlayerInfo? { + val now = System.currentTimeMillis() + if (now >= playersByNameExpireAt) { + playersByName = mc.connection?.onlinePlayers.orEmpty() + .mapNotNull { info -> info.profile?.name?.let { it.lowercase(Locale.ROOT) to info } } + .toMap() + playersByNameExpireAt = now + PLAYER_INDEX_TTL_MILLIS + } + return playersByName[name.lowercase(Locale.ROOT)] } fun clear() { synchronized(senders) { senders.clear() } + lineSenders.clear() + playersByName = emptyMap() + playersByNameExpireAt = 0L playerIndex = emptyList() playerIndexExpireAt = 0L } diff --git a/src/main/kotlin/top/fpsmaster/command/impl/ConfigCommand.kt b/src/main/kotlin/top/fpsmaster/command/impl/ConfigCommand.kt index e0a12db..ad8ca3a 100644 --- a/src/main/kotlin/top/fpsmaster/command/impl/ConfigCommand.kt +++ b/src/main/kotlin/top/fpsmaster/command/impl/ConfigCommand.kt @@ -10,7 +10,7 @@ class ConfigCommand : Command( name = "config", aliases = listOf("cfg"), description = "创建或加载配置", - usage = "config [args]" + usage = "config [args]" ) { override fun execute(context: CommandContext) { context.requireMinimumArgumentCount(1) @@ -89,13 +89,19 @@ class ConfigCommand : Command( context.replyInfo("当前配置: ${ConfigManager.activeName()}") } + "rollback" -> { + context.requireArgumentCount(1) + val label = ConfigManager.rollbackActive() + context.replySuccess(top.fpsmaster.translation.Language.get("configbackup.rollback.done").format(label)) + } + else -> throw CommandExecutionException("不支持的配置操作: $action") } } override fun complete(context: CompletionContext): List { return when (context.argumentIndex) { - 0 -> listOf("create", "load", "save", "delete", "rename", "import", "export", "alloff", "list", "current") + 0 -> listOf("create", "load", "save", "delete", "rename", "import", "export", "alloff", "list", "current", "rollback") .filter { it.startsWith(context.currentArgument, ignoreCase = true) } 1 -> { diff --git a/src/main/kotlin/top/fpsmaster/config/AtomicFiles.kt b/src/main/kotlin/top/fpsmaster/config/AtomicFiles.kt new file mode 100644 index 0000000..3b59fb6 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/config/AtomicFiles.kt @@ -0,0 +1,53 @@ +package top.fpsmaster.config + +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermissions +import kotlin.io.path.deleteIfExists + +/** + * Whole-file replacement that never leaves a truncated file behind: write a sibling temp file, + * fsync it, then rename it over the target. A crash or power cut mid-write leaves either the old + * file or the new one. + */ +object AtomicFiles { + /** + * [ownerOnly]: on POSIX file systems the file is created rw------- (credentials). Windows keeps + * the per-user ACL inherited from the profile directory. + */ + fun write(path: Path, content: String, ownerOnly: Boolean = false) { + Files.createDirectories(path.parent) + // pid + nanoTime: two clients (or two threads) writing the same target never share a temp. + val tempPath = path.resolveSibling( + "${path.fileName}.${ProcessHandle.current().pid()}.${System.nanoTime()}.tmp" + ) + try { + FileChannel.open(tempPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE).use { channel -> + if (ownerOnly && supportsPosix(path)) { + Files.setPosixFilePermissions(tempPath, PosixFilePermissions.fromString("rw-------")) + } + val buffer = ByteBuffer.wrap(content.toByteArray(Charsets.UTF_8)) + while (buffer.hasRemaining()) { + channel.write(buffer) + } + channel.force(true) + } + try { + Files.move(tempPath, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (unsupported: AtomicMoveNotSupportedException) { + // Some network drives cannot rename atomically; a plain replace is the best left. + Files.move(tempPath, path, StandardCopyOption.REPLACE_EXISTING) + } + } finally { + tempPath.deleteIfExists() + } + } + + private fun supportsPosix(path: Path): Boolean = + runCatching { path.fileSystem.supportedFileAttributeViews().contains("posix") }.getOrDefault(false) +} diff --git a/src/main/kotlin/top/fpsmaster/config/ConfigBackups.kt b/src/main/kotlin/top/fpsmaster/config/ConfigBackups.kt new file mode 100644 index 0000000..45fe9f1 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/config/ConfigBackups.kt @@ -0,0 +1,81 @@ +package top.fpsmaster.config + +import java.nio.file.Files +import java.nio.file.Path +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.readText + +/** + * Rolling copies of each profile under `//.json`, newest last by name. + * + * Saves happen on every toggle and slider drag, so a copy is only taken when the content changed + * and at most once per [minIntervalMs] (or when forced: the known-good file at startup, the state + * just before a rollback). The oldest copies beyond [keep] are deleted. + */ +class ConfigBackups( + private val root: Path, + private val keep: Int = 10, + private val minIntervalMs: Long = 10 * 60_000L, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val lastTaken = HashMap() + + /** Returns the new backup, or null when none was needed. */ + @Synchronized + fun snapshot(profile: String, content: String, force: Boolean = false): Path? { + val now = clock() + if (!force && now - (lastTaken[profile] ?: Long.MIN_VALUE / 2) < minIntervalMs) { + return null + } + val newest = list(profile).firstOrNull() + if (newest != null && runCatching { newest.readText() }.getOrNull() == content) { + lastTaken[profile] = now + return null + } + val directory = root.resolve(profile) + var target = directory.resolve("${stamp(now)}.json") + var suffix = 1 + while (target.exists()) { + target = directory.resolve("${stamp(now)}-${suffix++}.json") + } + AtomicFiles.write(target, content) + lastTaken[profile] = now + list(profile).drop(keep).forEach { Files.deleteIfExists(it) } + return target + } + + /** Newest first. */ + @Synchronized + fun list(profile: String): List { + val directory = root.resolve(profile) + if (!directory.isDirectory()) { + return emptyList() + } + return directory.listDirectoryEntries("*.json").sortedByDescending { it.name } + } + + /** The newest backup whose content differs from [current]: what "roll back" should restore. */ + @Synchronized + fun latestDifferentFrom(profile: String, current: String?): Path? = + list(profile).firstOrNull { runCatching { it.readText() }.getOrNull() != current } + + companion object { + private fun stamp(millis: Long): String = + SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.ROOT).format(Date(millis)) + + /** "2026-10-16 12:30" for a backup file name produced by [snapshot]. */ + fun label(backup: Path): String { + val raw = backup.fileName.toString().removeSuffix(".json") + return runCatching { + val date = SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.ROOT).parse(raw.take(19)) + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ROOT).format(date) + }.getOrDefault(raw) + } + } +} diff --git a/src/main/kotlin/top/fpsmaster/config/ConfigManager.kt b/src/main/kotlin/top/fpsmaster/config/ConfigManager.kt index 75adb45..646e4d3 100644 --- a/src/main/kotlin/top/fpsmaster/config/ConfigManager.kt +++ b/src/main/kotlin/top/fpsmaster/config/ConfigManager.kt @@ -28,23 +28,42 @@ import net.minecraft.core.registries.BuiltInRegistries //?} else { /*import net.minecraft.core.Registry as BuiltInRegistries*/ //?} -import java.nio.ByteBuffer -import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.Locale import kotlin.io.path.deleteIfExists import kotlin.io.path.exists import kotlin.io.path.isRegularFile import kotlin.io.path.listDirectoryEntries import kotlin.io.path.reader +import kotlin.io.path.readText object ConfigManager { private val gson = GsonBuilder().setPrettyPrinting().create() private val saveLock = Any() + + /** + * Profile writes (fsync included) run here, not on the render thread: a module hotkey or a + * slider drag in the ClickGUI saves, and on a slow disk or under an antivirus scan that was a + * visible hitch. Latest-wins per path: a burst of saves writes the final state once. Anything + * that reads or replaces a profile file first calls [awaitWrites]. + */ + private val io = Executors.newSingleThreadExecutor { task -> + Thread(task, "fpsmaster-config-io").apply { isDaemon = true } + } + private val pendingWrites = ConcurrentHashMap() + + private val backups by lazy { ConfigBackups(ensureConfigDirectory().resolve("backups")) } + + /** Something the player should hear about from startup (restore from backup, reset). */ + @Volatile + private var startupNotice: Pair? = null + private val validNameRegex = Regex("^[a-zA-Z0-9._-]+$") private const val DEFAULT_CONFIG_NAME = "default" private const val ACTIVE_PROFILE_STATE = "active_profile.txt" @@ -87,10 +106,21 @@ object ConfigManager { if (path.exists() && path.isRegularFile()) { try { load(profileName) - } catch (exception: CommandExecutionException) { - val backupPath = backupInvalidConfig(path) - logger.warn("Failed to load config '{}'; backed it up to {}", profileName, backupPath.fileName, exception) - saveDefault() + // The file just loaded is known to be good: keep a copy to fall back on. + runCatching { backups.snapshot(profileName, path.readText(), force = true) } + .onFailure { logger.warn("Could not back up config '{}'", profileName, it) } + } catch (exception: Exception) { + // Any failure, not only the ones load() anticipates: a crash here would repeat on + // every launch until the player found and deleted the file by hand. + val invalidPath = backupInvalidConfig(path) + logger.warn("Failed to load config '{}'; moved it to {}", profileName, invalidPath.fileName, exception) + val restored = restoreFromBackups(profileName, path) + startupNotice = if (restored != null) { + "configbackup.restored" to ConfigBackups.label(restored) + } else { + saveDefault() + "configbackup.reset" to invalidPath.fileName.toString() + } } } else { saveDefault() @@ -99,6 +129,54 @@ object ConfigManager { rewriteConvertedEdgeProfile() } + /** + * Newest to oldest, the first backup that loads cleanly becomes the profile again. Null when + * none does; the profile keeps whatever the failed attempts applied and gets rewritten. + */ + private fun restoreFromBackups(profileName: String, path: Path): Path? { + for (backup in backups.list(profileName)) { + try { + loadFrom(backup, profileName) + AtomicFiles.write(path, backup.readText()) + logger.info("Restored config '{}' from backup {}", profileName, backup.fileName) + return backup + } catch (exception: Exception) { + logger.warn("Backup {} of '{}' does not load either", backup.fileName, profileName, exception) + } + } + return null + } + + /** Title and description keys plus argument for the one-time startup notice, if any. */ + fun takeStartupNotice(): Pair? = startupNotice.also { startupNotice = null } + + /** + * Puts the active profile back to its newest backup that differs from what is on disk now. The + * current state is backed up first, so a second rollback undoes the first. Returns the label of + * the restored backup. + */ + fun rollbackActive(): String { + awaitWrites() + val path = configPath(activeProfileName) + val current = runCatching { path.readText() }.getOrNull() + val target = backups.latestDifferentFrom(activeProfileName, current) + ?: throw CommandExecutionException(top.fpsmaster.translation.Language.get("configbackup.none")) + val restored = target.readText() + current?.let { backups.snapshot(activeProfileName, it, force = true) } + loadFrom(target, activeProfileName) + AtomicFiles.write(path, restored) + return ConfigBackups.label(target) + } + + /** Waits for queued profile writes, so files are current before they are read or replaced. */ + fun awaitWrites() { + try { + io.submit {}.get(10, TimeUnit.SECONDS) + } catch (exception: Exception) { + logger.warn("Timed out waiting for config writes", exception) + } + } + fun saveDefault() { saveActive() } @@ -139,11 +217,15 @@ object ConfigManager { } fun load(name: String) { + awaitWrites() val path = configPath(name) if (!path.exists() || !path.isRegularFile()) { throw CommandExecutionException("配置不存在: $name") } + loadFrom(path, name) + } + private fun loadFrom(path: Path, name: String) { val root = try { val json = path.reader().use { JsonParser.parseReader(it) } json?.takeIf { it.isJsonObject }?.asJsonObject @@ -159,8 +241,13 @@ object ConfigManager { return } - val config = gson.fromJson(root, ConfigFile::class.java) - ?: throw CommandExecutionException("配置文件无效: $name") + // Valid JSON with a field of the wrong type (hand edits, a value type that changed between + // versions) throws here rather than in the parser. + val config = try { + gson.fromJson(root, ConfigFile::class.java) + } catch (exception: JsonParseException) { + throw CommandExecutionException("配置文件无效: $name") + } ?: throw CommandExecutionException("配置文件无效: $name") config.modules.forEach { moduleEntry -> val module = ModuleManager.modules[moduleEntry.id.lowercase()] ?: return@forEach @@ -171,7 +258,16 @@ object ConfigManager { if (value == null) { collectLegacyColorChannel(legacyChannels, valueEntry.key, valueEntry.value) } else { - applyValue(value, valueEntry.value) + // One value of the wrong shape (a string where a number was, an object where a + // boolean was) costs that value, not the whole profile. + try { + applyValue(value, valueEntry.value) + } catch (exception: RuntimeException) { + logger.warn( + "Ignoring config value {}.{}: {}", + module.identity, valueEntry.key, exception.toString() + ) + } } } applyLegacyColorChannels(module, legacyChannels) @@ -212,6 +308,7 @@ object ConfigManager { } fun delete(name: String) { + awaitWrites() val profileName = sanitizeName(name) if (profileName == DEFAULT_CONFIG_NAME) { throw CommandExecutionException("不能删除默认配置") @@ -228,6 +325,7 @@ object ConfigManager { } fun rename(oldName: String, newName: String): String { + awaitWrites() val sourceName = sanitizeName(oldName) val targetName = sanitizeName(newName) if (sourceName == DEFAULT_CONFIG_NAME) { @@ -261,12 +359,14 @@ object ConfigManager { val fileName = sourcePath.fileName.toString().removeSuffix(".json") val profileName = sanitizeName(fileName) + awaitWrites() Files.copy(sourcePath, configPath(profileName), StandardCopyOption.REPLACE_EXISTING) return profileName } fun exportActive(target: String) { saveActive() + awaitWrites() val targetPath = Paths.get(target) targetPath.parent?.let { Files.createDirectories(it) } Files.copy(configPath(activeProfileName), targetPath, StandardCopyOption.REPLACE_EXISTING) @@ -278,6 +378,7 @@ object ConfigManager { if (profile == activeProfileName) { saveActive() } + awaitWrites() val source = configPath(profile) if (!source.exists() || !source.isRegularFile()) { throw CommandExecutionException("档案不存在: $name") @@ -384,8 +485,32 @@ object ConfigManager { } ) - synchronized(saveLock) { - writeJsonAtomically(path, config) + enqueueWrite(path, gson.toJson(config)) + } + + private fun enqueueWrite(path: Path, content: String) { + if (pendingWrites.put(path, content) != null) { + return // the queued task has not run yet and will pick this newer content up + } + io.execute { + val latest = pendingWrites.remove(path) ?: return@execute + try { + synchronized(saveLock) { + AtomicFiles.write(path, latest) + } + val profile = path.fileName.toString().removeSuffix(".json") + backups.snapshot(profile, latest) + } catch (exception: Exception) { + logger.error("Failed to save config {}", path.fileName, exception) + mc.execute { + top.fpsmaster.notification.NotificationManager.add( + top.fpsmaster.translation.Language.get("configbackup.title"), + top.fpsmaster.translation.Language.get("configbackup.savefailed"), + 4f, + top.fpsmaster.prism.overlay.NotificationCenter.Type.ERROR + ) + } + } } } @@ -974,7 +1099,7 @@ object ConfigManager { private fun saveActiveProfileName() { synchronized(saveLock) { - writeTextAtomically(activeProfileStatePath(), activeProfileName) + AtomicFiles.write(activeProfileStatePath(), activeProfileName) } } @@ -984,38 +1109,6 @@ object ConfigManager { return backupPath } - private fun writeJsonAtomically(path: Path, value: Any) { - writeTextAtomically(path, gson.toJson(value)) - } - - private fun writeTextAtomically(path: Path, content: String) { - Files.createDirectories(path.parent) - val tempPath = path.resolveSibling("${path.fileName}.${System.nanoTime()}.tmp") - try { - FileChannel.open( - tempPath, - StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE - ).use { channel -> - val buffer = ByteBuffer.wrap(content.toByteArray(Charsets.UTF_8)) - while (buffer.hasRemaining()) { - channel.write(buffer) - } - channel.force(true) - } - Files.move( - tempPath, - path, - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - } catch (exception: java.nio.file.AtomicMoveNotSupportedException) { - Files.move(tempPath, path, StandardCopyOption.REPLACE_EXISTING) - } finally { - tempPath.deleteIfExists() - } - } - private fun migrateLegacyDefaultIfNeeded() { val legacyPath = mc.gameDirectory.toPath() .resolve("fpsmaster") diff --git a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutCache.kt b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutCache.kt index f3aae07..c8a0d41 100644 --- a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutCache.kt +++ b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutCache.kt @@ -21,8 +21,11 @@ import java.util.concurrent.ConcurrentHashMap * read the map — refreshing happens on the client tick. */ object CosmeticLoadoutCache { + /** Also the negative-cache lifetime: a player without cosmetics is asked about again after it. */ private const val TTL_MS = 5 * 60 * 1000L private const val REFRESH_INTERVAL_MS = 60 * 1000L + private const val FIRST_BACKOFF_MS = 5_000L + private const val MAX_BACKOFF_MS = 5 * 60 * 1000L data class Loadout( val capeItemId: String?, @@ -40,6 +43,17 @@ object CosmeticLoadoutCache { @Volatile private var lastSweep = 0L + /** + * Failed resolves back off exponentially (5 s, 10 s, ... 5 min) instead of being retried on the + * next tick: failures store no entries, so every online player stays "unknown" and the tick + * would otherwise fire a new batch the moment the last one returned. + */ + @Volatile + private var backoffMs = 0L + + @Volatile + private var retryAt = 0L + @Volatile private var registered = false @@ -102,15 +116,23 @@ object CosmeticLoadoutCache { FPSMasterApiClient.resolveLoadouts(keys).whenComplete { result, exception -> val fetchedAt = System.currentTimeMillis() if (exception != null || !result.success) { - // Remember nothing on failure; the next sweep retries. Players render vanilla meanwhile. + // Players render vanilla meanwhile. 404 = the endpoint is not deployed, which will + // not change within seconds: wait the longest interval straight away. + backoffMs = if (result?.statusCode == 404) MAX_BACKOFF_MS + else if (backoffMs == 0L) FIRST_BACKOFF_MS + else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + retryAt = fetchedAt + backoffMs logger.warn( - "Failed to resolve {} remote cosmetic loadouts: {}", + "Failed to resolve {} remote cosmetic loadouts ({}); retrying in {}s", batch.size, - exception?.message ?: result.message + exception?.javaClass?.simpleName ?: result.code ?: "HTTP ${result.statusCode}", + backoffMs / 1000 ) batch.forEach(inFlight::remove) return@whenComplete } + backoffMs = 0L + retryAt = 0L val resolved = HashMap() result.data.orEmpty().forEach { view -> @@ -129,6 +151,8 @@ object CosmeticLoadoutCache { entries.clear() inFlight.clear() lastSweep = 0L + backoffMs = 0L + retryAt = 0L } @EventHandler @@ -140,9 +164,12 @@ object CosmeticLoadoutCache { return } - val online = connection.onlinePlayers.mapNotNull { it.profile?.id } val now = System.currentTimeMillis() - val unknown = online.any { entries[it] == null } + if (now < retryAt) { + return + } + val online = connection.onlinePlayers.mapNotNull { it.profile?.id } + val unknown = online.any { entries[it] == null && it !in inFlight } // New faces are looked up as soon as they appear; everyone else on the interval. if (!unknown && now - lastSweep < REFRESH_INTERVAL_MS) { return diff --git a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutClient.kt b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutClient.kt index d5d52d5..5bd89dc 100644 --- a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutClient.kt +++ b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticLoadoutClient.kt @@ -64,9 +64,6 @@ object CosmeticLoadoutClient { @Volatile private var lastPushed: CosmeticLoadoutRequest? = null - @Volatile - private var linkAttempted = false - fun initialize() { StandaloneEventAPI.getApi().register(this) pull() @@ -130,7 +127,6 @@ object CosmeticLoadoutClient { cancelPending() lastView = null lastPushed = null - linkAttempted = false status = Status.OK lastError = "" } @@ -146,11 +142,11 @@ object CosmeticLoadoutClient { @Suppress("unused") fun onTick(@Suppress("UNUSED_PARAMETER") event: TickEvent) { // Proving the Minecraft account needs a live session, so it waits for a world rather than - // running at start-up. One attempt per session; failures do not retry in a loop. - if (linkAttempted || mc.player == null || !AuthService.isLoggedIn()) { + // running at start-up. ensureLinked is a no-op once linked, for offline sessions, and while + // backing off after a failure, so calling it every tick does not loop on errors. + if (mc.player == null || !AuthService.isLoggedIn()) { return } - linkAttempted = true MinecraftLinkClient.ensureLinked() } diff --git a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticManager.kt b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticManager.kt index 4efc417..3aa45a7 100644 --- a/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticManager.kt +++ b/src/main/kotlin/top/fpsmaster/cosmetic/CosmeticManager.kt @@ -18,7 +18,6 @@ import top.fpsmaster.identifier import top.fpsmaster.logger import top.fpsmaster.mc import top.fpsmaster.runtime.RuntimeProbe -import java.awt.Desktop import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.net.HttpURLConnection @@ -51,12 +50,23 @@ object CosmeticManager { } private const val MAX_BYTES = 16 * 1024 * 1024 + private const val TEXTURE_FIRST_BACKOFF_MS = 30_000L + private const val TEXTURE_MAX_BACKOFF_MS = 30 * 60_000L private val executor = Executors.newSingleThreadExecutor { task -> Thread(task, "FPSMaster-Cosmetics").apply { isDaemon = true } } private val textures = HashMap() private val loading = HashSet() + /** + * Textures that failed to load, with when to try again. The render path asks for a texture on + * every frame a wearer is visible; without this a 404 or a malformed PNG was re-downloaded and + * re-logged every frame. Guarded by [textures]. + */ + private val failedTextures = HashMap() + + private class FailedTexture(val retryAt: Long, val backoffMs: Long) + @Volatile private var previewing = false @@ -190,6 +200,8 @@ object CosmeticManager { } fun reloadCustom() { + // The player may just have fixed a broken local texture: let local ones load again. + synchronized(textures) { failedTextures.keys.removeAll { id -> customOptions.any { it.id == id } } } val directory = customDirectory() try { Files.createDirectories(directory) @@ -217,14 +229,14 @@ object CosmeticManager { fun openCustomDirectory() { reloadCustom() + val directory = customDirectory() try { - if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { - throw UnsupportedOperationException("Desktop folder opening is unavailable") - } - Desktop.getDesktop().open(customDirectory().toFile()) + Files.createDirectories(directory) } catch (exception: Exception) { - logger.warn("Failed to open custom cosmetics folder: ${exception.message}") + logger.warn("Failed to create custom cosmetics folder: ${exception.message}") + return } + top.fpsmaster.ui.kit.NativeDialogs.openFolder(directory) } fun allOptions(): List = (listOf(BUILTIN_WINGS) + customOptions + catalogOptions + ownedOptions) @@ -366,7 +378,10 @@ object CosmeticManager { */ fun textureForRemote(item: ItemView): TextureId? { val id = item.id.toString() - synchronized(textures) { textures[id] }?.let { return it } + synchronized(textures) { + textures[id]?.let { return it } + if (coolingDown(id)) return null + } val known = allOptions().firstOrNull { it.id == id } ?: option(item).also { candidate -> if (candidate.assetKey.isNullOrBlank()) return null } @@ -397,7 +412,7 @@ object CosmeticManager { private fun loadTexture(option: CosmeticOption) { val assetKey = option.assetKey ?: return synchronized(textures) { - if (option.id in textures || !loading.add(option.id)) return + if (option.id in textures || coolingDown(option.id) || !loading.add(option.id)) return } executor.execute { try { @@ -414,6 +429,7 @@ object CosmeticManager { synchronized(textures) { textures[option.id] = textureId loading.remove(option.id) + failedTextures.remove(option.id) } } catch (exception: Exception) { image.close() @@ -437,7 +453,12 @@ object CosmeticManager { connection.readTimeout = 20_000 connection.setRequestProperty("User-Agent", "FPSMaster-Nova/${top.fpsmaster.Client.VERSION}") return try { - require(connection.responseCode in 200..299) { "cosmetic download returned ${connection.responseCode}" } + val code = connection.responseCode + if (code == 429 || code >= 500) { + // Transient: retried on the short backoff, unlike a 404 or a bad file. + throw java.io.IOException("cosmetic download returned $code") + } + require(code in 200..299) { "cosmetic download returned $code" } connection.inputStream.use { input -> val output = ByteArrayOutputStream() val buffer = ByteArray(8192) @@ -470,9 +491,25 @@ object CosmeticManager { require(validAtlas) { "cosmetic atlas dimensions do not match $category" } } + /** Caller holds [textures]. */ + private fun coolingDown(id: String): Boolean { + val failure = failedTextures[id] ?: return false + return System.currentTimeMillis() < failure.retryAt + } + private fun fail(option: CosmeticOption, exception: Exception) { - synchronized(textures) { loading.remove(option.id) } - logger.warn("Failed to load cosmetic ${option.id}: ${exception.message}") + val backoff = synchronized(textures) { + loading.remove(option.id) + val previous = failedTextures[option.id]?.backoffMs ?: 0L + // A bad file stays bad (validation, 4xx): no point asking again soon. Network errors + // start at 30 s and double. + val next = if (exception is IllegalArgumentException) TEXTURE_MAX_BACKOFF_MS + else if (previous == 0L) TEXTURE_FIRST_BACKOFF_MS + else (previous * 2).coerceAtMost(TEXTURE_MAX_BACKOFF_MS) + failedTextures[option.id] = FailedTexture(System.currentTimeMillis() + next, next) + next + } + logger.warn("Failed to load cosmetic ${option.id}: ${exception.message}; next try in ${backoff / 1000}s") } private fun option(item: ItemView) = CosmeticOption( diff --git a/src/main/kotlin/top/fpsmaster/hud/HudComponent.kt b/src/main/kotlin/top/fpsmaster/hud/HudComponent.kt index 5ccce26..1347fe5 100644 --- a/src/main/kotlin/top/fpsmaster/hud/HudComponent.kt +++ b/src/main/kotlin/top/fpsmaster/hud/HudComponent.kt @@ -65,8 +65,9 @@ abstract class HudComponent( fun height(preview: Boolean): Float = measure(preview).height.coerceAtLeast(1f) * scale fun adaptToSurface(surfaceWidth: Float, surfaceHeight: Float, preview: Boolean) { - val availableX = (surfaceWidth - width(preview)).coerceAtLeast(0f) - val availableY = (surfaceHeight - height(preview)).coerceAtLeast(0f) + val size = measure(preview) + val availableX = (surfaceWidth - size.width.coerceAtLeast(1f) * scale).coerceAtLeast(0f) + val availableY = (surfaceHeight - size.height.coerceAtLeast(1f) * scale).coerceAtLeast(0f) if (relativeX.isNaN()) relativeX = if (availableX == 0f) 0f else (x / availableX).coerceIn(0f, 1f) if (relativeY.isNaN()) relativeY = if (availableY == 0f) 0f else (y / availableY).coerceIn(0f, 1f) x = relativeX * availableX diff --git a/src/main/kotlin/top/fpsmaster/hud/HudManager.kt b/src/main/kotlin/top/fpsmaster/hud/HudManager.kt index e1c6441..89580b8 100644 --- a/src/main/kotlin/top/fpsmaster/hud/HudManager.kt +++ b/src/main/kotlin/top/fpsmaster/hud/HudManager.kt @@ -96,6 +96,10 @@ object HudManager { } *///?} + /** Bumped once per HUD frame; components may cache per-frame work against it. */ + var frame = 0L + private set + fun renderHud(guiGraphics: GuiGraphics) { if (mc.player == null || hideGuiCompat) { return @@ -105,19 +109,23 @@ object HudManager { val scale = ClientSettings.hudRenderScale() val surfaceWidth = mc.window.guiScaledWidth / scale val surfaceHeight = mc.window.guiScaledHeight / scale - components.values.forEach { it.adaptToSurface(surfaceWidth, surfaceHeight, preview = false) } + frame++ + // Disabled components are neither measured nor rendered: measuring some of them is not free + // (TNTTimer scans every entity), and that used to run for all of them, twice, every frame. + val shown = components.values.filter { it.shouldRender() } + shown.forEach { it.adaptToSurface(surfaceWidth, surfaceHeight, preview = false) } //? if >=1.21.5 { pose.pushMatrix() pose.scale(scale, scale) - components.values.forEach { component -> - component.render(guiGraphics, preview = false) + shown.forEach { component -> + component.renderAt(guiGraphics, component.x, component.y, component.scale, preview = false) } pose.popMatrix() //?} else { /*pose.pushPose() pose.scale(scale, scale, 1f) - components.values.forEach { component -> - component.render(guiGraphics, preview = false) + shown.forEach { component -> + component.renderAt(guiGraphics, component.x, component.y, component.scale, preview = false) } pose.popPose() *///?} diff --git a/src/main/kotlin/top/fpsmaster/hud/impl/ScoreboardHudComponent.kt b/src/main/kotlin/top/fpsmaster/hud/impl/ScoreboardHudComponent.kt index 4e46196..ca0e226 100644 --- a/src/main/kotlin/top/fpsmaster/hud/impl/ScoreboardHudComponent.kt +++ b/src/main/kotlin/top/fpsmaster/hud/impl/ScoreboardHudComponent.kt @@ -11,8 +11,11 @@ import net.minecraft.client.gui.GuiGraphics //?} import net.minecraft.network.chat.Component //? if >=1.20.5 { +import net.minecraft.network.chat.numbers.StyledFormat import net.minecraft.world.scores.DisplaySlot +import net.minecraft.world.scores.PlayerScoreEntry //?} +import net.minecraft.world.scores.PlayerTeam import top.fpsmaster.hud.HudComponent import top.fpsmaster.hud.HudSize import top.fpsmaster.mc @@ -66,29 +69,49 @@ class ScoreboardHudComponent : HudComponent( val level = mc.level ?: return null val scoreboard = level.scoreboard + // Same selection and order as vanilla's Gui.displayScoreboardSidebar: the team-colour sidebar + // slot wins over the plain one, the 15 highest scores are shown top-down (ties by name), and + // each owner is wrapped in its team prefix/suffix — Hypixel-style boards put all of the + // visible text there and use the owner only as an invisible unique key. + val playerTeam = mc.player?.let { scoreboard.getPlayersTeam(it.scoreboardName) } //? if >=1.20.5 { - val objective = scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR) ?: return null + //? if >=26 { + /*val teamSlot = playerTeam?.color?.orElse(null)?.displaySlot() + *///?} else { + val teamSlot = playerTeam?.let { DisplaySlot.teamColorToSlot(it.color) } + //?} + val teamObjective = teamSlot?.let { scoreboard.getDisplayObjective(it) } + val objective = teamObjective ?: scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR) ?: return null + val numberFormat = objective.numberFormatOrDefault(StyledFormat.SIDEBAR_DEFAULT) val lines = scoreboard.listPlayerScores(objective) - .filter { !it.isHidden && !it.owner().startsWith("#") } - .takeLast(MAX_LINES) + .filter { !it.isHidden } + .sortedWith(SCORE_DISPLAY_ORDER) + .take(MAX_LINES) .map { entry -> - if (Scoreboard.shouldShowScore()) { - entry.ownerName().copy().append(Component.literal(": ${entry.value()}")) + val name = PlayerTeam.formatNameForTeam(scoreboard.getPlayersTeam(entry.owner()), entry.ownerName()) + val value = entry.formatValue(numberFormat) + if (Scoreboard.shouldShowScore() && value.string.isNotEmpty()) { + name.copy().append(Component.literal(": ")).append(value) } else { - entry.ownerName() + name } } //?} else { - /*val objective = scoreboard.getDisplayObjective(1) ?: return null + /*val teamColor = playerTeam?.color?.id ?: -1 + val teamObjective = if (teamColor >= 0) scoreboard.getDisplayObjective(3 + teamColor) else null + val objective = teamObjective ?: scoreboard.getDisplayObjective(1) ?: return null + // getPlayerScores is already sorted ascending (Score.SCORE_COMPARATOR, ties by name), and + // vanilla draws the last 15 bottom-up: highest score on top. val lines = scoreboard.getPlayerScores(objective) - .filter { !it.owner.startsWith("#") } - .sortedBy { it.score } + .filter { it.owner != null && !it.owner.startsWith("#") } .takeLast(MAX_LINES) + .asReversed() .map { entry -> + val name = PlayerTeam.formatNameForTeam(scoreboard.getPlayersTeam(entry.owner), Component.literal(entry.owner)) if (Scoreboard.shouldShowScore()) { - Component.literal(entry.owner).append(Component.literal(": ${entry.score}")) + name.append(Component.literal(": ${entry.score}")) } else { - Component.literal(entry.owner) + name } } *///?} @@ -110,6 +133,11 @@ class ScoreboardHudComponent : HudComponent( ) companion object { + //? if >=1.20.5 { + private val SCORE_DISPLAY_ORDER: Comparator = + compareByDescending { it.value() } + .thenComparing({ it.owner() }, String.CASE_INSENSITIVE_ORDER) + //?} private const val MAX_LINES = 15 private const val LINE_HEIGHT = 10 private const val PADDING_X = 3 diff --git a/src/main/kotlin/top/fpsmaster/hud/impl/TNTTimerHudComponent.kt b/src/main/kotlin/top/fpsmaster/hud/impl/TNTTimerHudComponent.kt index c721993..14deac6 100644 --- a/src/main/kotlin/top/fpsmaster/hud/impl/TNTTimerHudComponent.kt +++ b/src/main/kotlin/top/fpsmaster/hud/impl/TNTTimerHudComponent.kt @@ -38,7 +38,20 @@ class TNTTimerHudComponent : HudComponent( } } + private var cachedFrame = -1L + private var cachedEntries: List = emptyList() + + /** shouldRender, measure and render all need this within one frame: scan the entities once. */ private fun resolveEntries(): List { + val frame = top.fpsmaster.hud.HudManager.frame + if (frame != cachedFrame) { + cachedFrame = frame + cachedEntries = scanEntries() + } + return cachedEntries + } + + private fun scanEntries(): List { val player = mc.player ?: return emptyList() return mc.level?.entitiesForRendering() ?.asSequence() diff --git a/src/main/kotlin/top/fpsmaster/module/ModuleManager.kt b/src/main/kotlin/top/fpsmaster/module/ModuleManager.kt index b87a0ba..2ebacff 100644 --- a/src/main/kotlin/top/fpsmaster/module/ModuleManager.kt +++ b/src/main/kotlin/top/fpsmaster/module/ModuleManager.kt @@ -151,18 +151,24 @@ class ModuleManager { TargetDisplay() ) StandaloneEventAPI.getApi().register(ModuleManager::class.java) + StandaloneEventAPI.getApi().register(top.fpsmaster.module.impl.auxiliary.ReplayHotkey::class.java) } @JvmStatic @Suppress("unused") @EventHandler fun onKey(e: KeyEvent) { + var toggled = false modules.values.forEach { if (it.key == e.key.value) { it.enabled = !it.enabled - ConfigManager.saveDefault() + toggled = true } } + // One save for every module bound to this key, not one each. + if (toggled) { + ConfigManager.saveDefault() + } } } } diff --git a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/ClientSettings.kt b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/ClientSettings.kt index defff74..f17785c 100644 --- a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/ClientSettings.kt +++ b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/ClientSettings.kt @@ -17,14 +17,14 @@ class ClientSettings : Module("client-settings", Category.AUXILIARY, canBeEnable blur, fixedScaleEnabled, fixedScale, - webViewScale, theme, zoomBind, clientCommand, commandPrefix, clickGuiKey, - interfaceAnimations, - hardwareAcceleration + interfaceAnimations + // webViewScale / hardwareAcceleration are not listed: nothing in the product opens a + // WebView any more (Client.openWebView has no caller), so the switches did nothing. ) ) } diff --git a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt index 036705a..1eb932d 100644 --- a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt +++ b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt @@ -1,10 +1,21 @@ package top.fpsmaster.module.impl.auxiliary +import io.github.vlouboos.standaloneevent.api.EventHandler +import net.minecraft.client.multiplayer.ClientPacketListener import net.minecraft.network.protocol.Packet +import org.lwjgl.glfw.GLFW +import top.fpsmaster.event.client.KeyEvent import top.fpsmaster.logger import top.fpsmaster.mc import top.fpsmaster.module.Category import top.fpsmaster.module.Module +import top.fpsmaster.module.ModuleManager +import top.fpsmaster.module.value.impl.KeyValue +import top.fpsmaster.notification.NotificationManager +import top.fpsmaster.prism.overlay.NotificationCenter +import top.fpsmaster.replay.ReplayClipBuffer +import top.fpsmaster.screenCompat +import top.fpsmaster.translation.Language import top.fpsmaster.module.value.impl.NumberValue import top.fpsmaster.module.value.impl.OptionValue import top.fpsmaster.replay.DirectorSession @@ -29,7 +40,7 @@ class Replay : Module("replay", Category.AUXILIARY) { override val persistEnabled: Boolean = false init { - values.addAll(arrayOf(exportFps, followRecorder)) + values.addAll(arrayOf(exportFps, followRecorder, autoRecord, clipBuffer, clipMinutes, clipKey)) } override fun onEnable() { @@ -46,6 +57,16 @@ class Replay : Module("replay", Category.AUXILIARY) { val exportFps = NumberValue("export-fps", 60.0, 24.0, 120.0, 1.0) val followRecorder = OptionValue("follow-recorder", true) + /** Start a full recording on joining a server, stop it on leaving. */ + val autoRecord = OptionValue("auto-record", false) + + /** Keep the last [clipMinutes] in memory so [clipKey] can save them. */ + val clipBuffer = OptionValue("clip-buffer", false) + val clipMinutes = NumberValue("clip-minutes", 2.0, 1.0, 10.0, 1.0) + val clipKey = KeyValue("clip-key", GLFW.GLFW_KEY_F7) { clipBuffer.getValue() } + + private var wasInWorld = false + /** The open replay, or null when nothing is being watched. */ @JvmStatic var session: DirectorSession? = null @@ -71,22 +92,75 @@ class Replay : Module("replay", Category.AUXILIARY) { * is feeding into the isolated listener, and recording them would record a recording. */ @JvmStatic - fun onClientboundPacket(packet: Packet<*>) { - if (session != null || !ReplayRecorder.isRecording) { + fun onClientboundPacket(packet: Packet<*>, listener: Any?) { + if (session != null) { return } - ReplayRecorder.onClientboundPacket(packet) + ReplayRecorder.onClientboundPacket(packet, (listener as? ClientPacketListener)?.registryAccess()) } /** Client thread, once per tick. */ @JvmStatic fun onClientTick() { + val inWorld = session == null && mc.player != null && mc.level != null + if (inWorld != wasInWorld) { + wasInWorld = inWorld + if (inWorld) onJoinWorld() else onLeaveWorld() + } if (ReplayRecorder.isRecording) { ReplayRecorder.onClientTick() } + if (inWorld && clipBuffer.getValue()) { + ReplayClipBuffer.tick((clipMinutes.getValue() * 60_000).toInt()) + } else if (ReplayClipBuffer.isBuffering) { + ReplayClipBuffer.stop() + } session?.tick() } + private fun module(): Replay? = ModuleManager.modules["replay"] as? Replay + + private fun onJoinWorld() { + // Servers only: "join a server and it records" is the feature; singleplayer worlds are + // left to the manual toggle. + if (autoRecord.getValue() && !mc.isLocalServer && !ReplayRecorder.isRecording) { + module()?.enabled = true + } + } + + private fun onLeaveWorld() { + // A recording that outlives its connection would carry on into the next server's packets + // in the same file. + if (ReplayRecorder.isRecording) { + module()?.enabled = false + } + ReplayClipBuffer.stop() + } + + /** Saves the buffered clip; bound to [clipKey]. */ + fun saveClip() { + if (!clipBuffer.getValue()) { + return + } + val title = Language.get("replay.clip.title") + when (ReplayClipBuffer.save(recordingsDirectory()) { file -> + if (file != null) { + NotificationManager.add(title, Language.get("replay.clip.saved").format(file.name), 3f, + NotificationCenter.Type.SUCCESS) + } else { + NotificationManager.add(title, Language.get("replay.clip.failed"), 3f, NotificationCenter.Type.ERROR) + } + }) { + ReplayClipBuffer.SaveResult.STARTED -> + NotificationManager.add(title, Language.get("replay.clip.saving"), 2f) + ReplayClipBuffer.SaveResult.BUSY -> + NotificationManager.add(title, Language.get("replay.clip.busy"), 2f, NotificationCenter.Type.WARNING) + ReplayClipBuffer.SaveResult.NOTHING_BUFFERED -> + NotificationManager.add(title, Language.get("replay.clip.empty"), 2f, NotificationCenter.Type.WARNING) + } + } + + /** Opens a recording for watching and editing. Returns the failure to show, or null. */ fun open(file: File): String? { close() @@ -114,6 +188,14 @@ class Replay : Module("replay", Category.AUXILIARY) { project.ensureDuration(playback.durationMillis) session = DirectorSession(playback, store, project) logger.info("[replay] playing ${file.name}") + if (playback.truncated) { + NotificationManager.add( + Language.get("replay.title"), + Language.get("replay.status.truncated").format(playback.durationMillis / 60_000), + 5f, + NotificationCenter.Type.WARNING + ) + } return null } @@ -127,3 +209,21 @@ class Replay : Module("replay", Category.AUXILIARY) { } } } + +/** + * The clip hotkey, registered on the event bus once at startup: it has to work while the Replay + * module — whose enabled state *is* "a recording is running" — is off. Kept out of [Replay] so that + * registering the module instance on enable does not subscribe it a second time. + */ +class ReplayHotkey { + companion object { + @JvmStatic + @EventHandler + fun onKey(event: KeyEvent) { + val key = Replay.clipKey + if (key.isBound() && event.key.value == key.getValue() && mc.screenCompat == null) { + Replay.saveClip() + } + } + } +} diff --git a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Sprint.kt b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Sprint.kt index adbdc23..9edff76 100644 --- a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Sprint.kt +++ b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Sprint.kt @@ -23,6 +23,9 @@ class Sprint : Module("sprint", Category.AUXILIARY) { sprinting = true } mc.options.keySprint.isDown = sprinting + if (top.fpsmaster.diagnostics.Smoke.ENABLED && mc.player?.isSprinting == true) { + top.fpsmaster.diagnostics.Smoke.feature("sprint") + } } @EventHandler diff --git a/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt b/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt index b6dc596..5b3621a 100644 --- a/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt +++ b/src/main/kotlin/top/fpsmaster/multiplayer/ServerBrowser.kt @@ -158,6 +158,29 @@ object ServerBrowser { return arranged } + /** + * The rows the multiplayer list last showed, in display order. Vanilla's move arrows and + * Shift+Up/Down pass *display* positions to `ServerList.swap`; with promoted rows inserted and + * pinned rows moved up those are not servers.dat positions (MixinOnlineServerEntry maps them). + * Client thread only. + */ + private var displayed: List = emptyList() + + fun rememberDisplayed(rows: List<*>) { + displayed = ArrayList(rows) + } + + /** + * servers.dat position of display row [displayIndex], or -1 for a promoted row (never in + * servers.dat) or an index outside the last arranged list. + */ + fun storedIndex(stored: List<*>, displayIndex: Int): Int = storedIndex(displayed, stored, displayIndex) + + internal fun storedIndex(displayed: List<*>, stored: List<*>, displayIndex: Int): Int { + val row = displayed.getOrNull(displayIndex) ?: return -1 + return stored.indexOfFirst { it === row } + } + private fun isHidden(server: PromotedServer): Boolean { return (server.id.isNotBlank() && ServerBrowserPrefs.isHidden(server.id)) || ServerBrowserPrefs.isHidden(normalize(server.address)) diff --git a/src/main/kotlin/top/fpsmaster/musicui/MusicController.kt b/src/main/kotlin/top/fpsmaster/musicui/MusicController.kt index 23afba2..76fb239 100644 --- a/src/main/kotlin/top/fpsmaster/musicui/MusicController.kt +++ b/src/main/kotlin/top/fpsmaster/musicui/MusicController.kt @@ -1,6 +1,9 @@ package top.fpsmaster.musicui +import top.fpsmaster.logger import top.fpsmaster.mc +import top.fpsmaster.music.MusicApiException +import top.fpsmaster.music.MusicErrorKind import top.fpsmaster.music.MusicService import top.fpsmaster.music.MusicSource import top.fpsmaster.music.Lyric @@ -9,17 +12,36 @@ import top.fpsmaster.music.Track import top.fpsmaster.music.store.MusicCredentialStore import top.fpsmaster.config.ConfigManager import top.fpsmaster.prism.screen.MusicBridge +import top.fpsmaster.translation.Language import java.io.File -import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ThreadLocalRandom - +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Music state behind the Prism music screen. Mutable state is only written on the client thread + * (worker results come back through [post]); workers only call the platform APIs. + * + * What is playing ([nowPlaying]) is tracked on its own rather than derived from `queue[index]`: + * searching or opening a playlist replaces the queue while the old song keeps playing. + */ object MusicController { private val service = MusicService() private val engine = AudioEngine() private val store = MusicCredentialStore.default("FPSMaster") - private val pool = Executors.newCachedThreadPool { runnable -> + + /** + * Music gets its own bounded pool: one Cadence request can take connect + total + read timeout + * (about 55 s) when a platform stalls, and an unbounded pool would pile up a thread per click. + */ + private val pool = ThreadPoolExecutor( + 3, 3, 30L, TimeUnit.SECONDS, LinkedBlockingQueue(32) + ) { runnable -> Thread(runnable, "fpsmaster-music").apply { isDaemon = true } - } + }.apply { allowCoreThreadTimeOut(true) } @Volatile var source: MusicSource = MusicSource.NETEASE @@ -33,9 +55,36 @@ object MusicController { var listTitle: String = "" private set + @Volatile + var tracksState: MusicBridge.ListState = MusicBridge.ListState.READY + private set + + @Volatile + var tracksError: String = "" + private set + + @Volatile + var playlistsState: MusicBridge.ListState = MusicBridge.ListState.READY + private set + + @Volatile + var playlistsError: String = "" + private set + private val queue = mutableListOf() private val playlists = mutableListOf() + + /** Position of [nowPlaying] in [queue], or -1 when it came from a list that has been replaced. */ private var index = -1 + + @Volatile + private var nowPlaying: Track? = null + + /** Bumped per list request / per play, so a slow response cannot overwrite a newer one. */ + private val listGeneration = AtomicInteger() + private val playlistGeneration = AtomicInteger() + private val playGeneration = AtomicInteger() + var playbackMode: MusicBridge.PlaybackMode = runCatching { MusicBridge.PlaybackMode.valueOf(ConfigManager.musicPlaybackMode) }.getOrDefault(MusicBridge.PlaybackMode.SEQUENTIAL) @@ -59,7 +108,10 @@ object MusicController { index = -1 listTitle = "" status = "" - lyric = null + listGeneration.incrementAndGet() + playlistGeneration.incrementAndGet() + tracksState = MusicBridge.ListState.READY + playlistsState = MusicBridge.ListState.READY } /** @@ -78,9 +130,10 @@ object MusicController { service.netease.cookie.contains("MUSIC_U") } - fun nowTitle(): String = current()?.name ?: "未在播放" + fun nowTitle(): String = current()?.name ?: Language.get("music.lyrics.idle") - fun nowArtist(): String = current()?.artists ?: if (qq()) "QQ 音乐" else "网易云音乐" + fun nowArtist(): String = current()?.artists + ?: Language.get(if (qq()) "music.source.qq" else "music.source.netease") fun coverUrl(): String? = current()?.coverUrl @@ -139,39 +192,44 @@ object MusicController { } fun next() { - if (queue.isEmpty()) { + val size = synchronized(queue) { queue.size } + if (size == 0) { return } - index = (index + 1) % queue.size - if (index >= 0) { - playTrack(queue[index]) - } + playAt(if (index < 0) 0 else (index + 1) % size) } fun prev() { - if (queue.isEmpty()) { + val size = synchronized(queue) { queue.size } + if (size == 0) { return } - index = (index - 1 + queue.size) % queue.size - playTrack(queue[index]) + playAt(if (index < 0) 0 else (index - 1 + size) % size) } fun play(i: Int) { - if (i !in queue.indices) { - return - } - index = i - playTrack(queue[i]) + playAt(i) } fun playLocal(tracks: List, i: Int) { if (i !in tracks.indices) return + replaceQueue(tracks) + playAt(i) + } + + private fun playAt(i: Int) { + val track = synchronized(queue) { queue.getOrNull(i) } ?: return + index = i + playTrack(track) + } + + /** Client thread. Re-finds the playing song in the new list so next/prev continue from it. */ + private fun replaceQueue(tracks: List) { synchronized(queue) { queue.clear() queue.addAll(tracks) } - index = i - playTrack(queue[i]) + index = nowPlaying?.let { playing -> tracks.indexOfFirst { it.id == playing.id && it.source == playing.source } } ?: -1 } fun snapshotTracks(): List = synchronized(queue) { queue.toList() } @@ -183,131 +241,172 @@ object MusicController { if (q.isEmpty()) { return } - status = "搜索中…" val src = source - pool.execute { - runCatching { service.search(src, q, 40) } - .onSuccess { tracks -> - post { - synchronized(queue) { - queue.clear() - queue.addAll(tracks) - } - listTitle = "搜索:$q" - status = if (tracks.isEmpty()) "没有结果" else "" - } - } - .onFailure { err -> - post { status = err.message ?: "搜索失败" } - } - } + loadTracks(Language.get("music.status.searchtitle").format(q)) { service.search(src, q, 40) } } + /** + * NetEase daily picks need a login. Nova has no music login screen (credentials come from the + * shared store, e.g. signed in through Edge), so without one — or once the platform reports the + * cookie expired — discover falls back to the hot list instead of a "sign in" dead end. + */ fun loadDiscover() { - status = "加载中…" val src = source - pool.execute { - val result = runCatching { - if (src == MusicSource.NETEASE) { - service.netease.getDailyRecommendSongs().ifEmpty { service.search(src, "热歌", 30) } - } else { - service.search(src, "热歌", 30) - } + val daily = src == MusicSource.NETEASE && loggedIn() + loadTracks(Language.get(if (daily) "music.status.daily" else "music.status.hot")) { + if (!daily) { + return@loadTracks service.search(src, HOT_KEYWORD, 30) } - result.onSuccess { tracks -> - post { - synchronized(queue) { - queue.clear() - queue.addAll(tracks) - } - listTitle = if (src == MusicSource.NETEASE) "每日推荐" else "热歌" - status = "" - } - }.onFailure { err -> - post { status = err.message ?: "加载失败" } + try { + service.netease.getDailyRecommendSongs().ifEmpty { service.search(src, HOT_KEYWORD, 30) } + } catch (expired: MusicApiException) { + if (expired.kind != MusicErrorKind.NOT_LOGGED_IN) throw expired + post { forgetNeteaseLogin() } + service.search(src, HOT_KEYWORD, 30) } } } fun loadPlaylists() { + val gen = playlistGeneration.incrementAndGet() if (source != MusicSource.NETEASE) { synchronized(playlists) { playlists.clear() } - status = "QQ 歌单暂未接入" + playlistsState = MusicBridge.ListState.ERROR + playlistsError = Language.get("music.error.qqplaylists") + return + } + if (!loggedIn()) { + synchronized(playlists) { playlists.clear() } + playlistsState = MusicBridge.ListState.NEED_LOGIN return } - status = "加载歌单…" - pool.execute { - runCatching { - val uid = service.netease.getLoginUid() ?: return@runCatching emptyList() - service.netease.getUserPlaylists(uid) - }.onSuccess { rows -> - post { + playlistsState = MusicBridge.ListState.LOADING + playlistsError = "" + submit({ failPlaylists(gen, null) }) { + val result = runCatching { + // null: the stored cookie is no longer valid. + service.netease.getLoginUid()?.let { service.netease.getUserPlaylists(it) } + } + post { + if (gen != playlistGeneration.get()) return@post + result.onSuccess { rows -> + if (rows == null) { + forgetNeteaseLogin() + synchronized(playlists) { playlists.clear() } + playlistsState = MusicBridge.ListState.NEED_LOGIN + return@onSuccess + } synchronized(playlists) { playlists.clear() playlists.addAll(rows) } - status = if (rows.isEmpty()) "" else "" - listTitle = "我的歌单" - } - }.onFailure { err -> - post { status = err.message ?: "歌单加载失败" } + playlistsState = MusicBridge.ListState.READY + listTitle = Language.get("music.status.myplaylists") + }.onFailure { failPlaylists(gen, it) } } } } fun openPlaylist(i: Int) { val item = synchronized(playlists) { playlists.getOrNull(i) } ?: return - status = "打开歌单…" - pool.execute { - runCatching { service.netease.getPlaylistTracks(item.id) } - .onSuccess { tracks -> - post { - synchronized(queue) { - queue.clear() - queue.addAll(tracks) - } - listTitle = item.name - status = "" - } - } - .onFailure { err -> - post { status = err.message ?: "歌单打开失败" } - } + loadTracks(item.name) { service.netease.getPlaylistTracks(item.id) } + } + + /** Client thread. Runs [request] on the pool and publishes the result as the track list. */ + private fun loadTracks(title: String, request: () -> List) { + val gen = listGeneration.incrementAndGet() + tracksState = MusicBridge.ListState.LOADING + tracksError = "" + submit({ failTracks(gen, null) }) { + val result = runCatching(request) + post { + if (gen != listGeneration.get()) return@post + result.onSuccess { tracks -> + replaceQueue(tracks) + listTitle = title + tracksState = MusicBridge.ListState.READY + }.onFailure { failTracks(gen, it) } + } } } - private fun current(): Track? = synchronized(queue) { queue.getOrNull(index) } + private fun failTracks(gen: Int, error: Throwable?) { + if (gen != listGeneration.get()) return + tracksState = stateFor(error) + tracksError = describe(error) + } + + private fun failPlaylists(gen: Int, error: Throwable?) { + if (gen != playlistGeneration.get()) return + if (error is MusicApiException && error.kind == MusicErrorKind.NOT_LOGGED_IN) { + forgetNeteaseLogin() + } + playlistsState = stateFor(error) + playlistsError = describe(error) + } + + private fun stateFor(error: Throwable?): MusicBridge.ListState = + if (error is MusicApiException && error.kind == MusicErrorKind.NOT_LOGGED_IN) MusicBridge.ListState.NEED_LOGIN + else MusicBridge.ListState.ERROR + + /** Localized reason; null = the request never ran because the pool was saturated. */ + private fun describe(error: Throwable?): String { + if (error == null) { + return Language.get("music.error.busy") + } + if (error !is MusicApiException) { + logger.warn("Music request failed", error) + return Language.get("music.error.unknown") + } + logger.warn("Music request failed ({}): {}", error.kind, error.message) + return Language.get("music.error." + error.kind.name.lowercase()) + } + + private fun forgetNeteaseLogin() { + store.clearNetease() + service.netease.cookie = "" + } + + /** [onRejected] runs on the client thread when the bounded pool is full. */ + private fun submit(onRejected: () -> Unit, task: () -> Unit) { + try { + pool.execute(task) + } catch (rejected: RejectedExecutionException) { + onRejected() + } + } + + private fun current(): Track? = nowPlaying private fun playTrack(track: Track) { - status = "获取链接…" + val gen = playGeneration.incrementAndGet() + nowPlaying = track + status = Language.get("music.status.fetching") lyric = null if (track.id.startsWith("file:")) { status = "" engine.playFile(File(java.net.URI.create(track.id)), track.durationMs, ::onEnded) return } - pool.execute { + submit({ status = describe(null) }) { runCatching { service.getLyric(track) } - .onSuccess { loaded -> post { if (current() == track) lyric = loaded } } + .onSuccess { loaded -> post { if (gen == playGeneration.get()) lyric = loaded } } } - pool.execute { - runCatching { service.getSongUrl(track) } - .onSuccess { url -> + submit({ status = describe(null) }) { + val result = runCatching { service.getSongUrl(track) } + post { + // A later click already started another song; this URL is for one nobody wants now. + if (gen != playGeneration.get()) return@post + result.onSuccess { url -> if (!url.available) { - post { status = url.reason ?: "无法播放" } + status = url.reason ?: Language.get("music.status.unplayable") return@onSuccess } val referer = if (track.source == MusicSource.QQ) "https://y.qq.com/" else null - post { - status = if (url.isTrial) "试听" else "" - engine.play(url.url, referer, track.durationMs) { - onEnded() - } - } - } - .onFailure { err -> - post { status = err.message ?: "播放失败" } - } + status = if (url.isTrial) Language.get("music.status.trial") else "" + engine.play(url.url, referer, track.durationMs) { onEnded() } + }.onFailure { status = describe(it) } + } } } @@ -315,20 +414,20 @@ object MusicController { val size = synchronized(queue) { queue.size } if (size == 0 || index < 0) return when (playbackMode) { - MusicBridge.PlaybackMode.REPEAT_ONE -> playTrack(queue[index]) + MusicBridge.PlaybackMode.REPEAT_ONE -> playAt(index) MusicBridge.PlaybackMode.SHUFFLE -> { var next = if (size == 1) index else ThreadLocalRandom.current().nextInt(size - 1) if (next >= index && size > 1) next++ - index = next - playTrack(queue[index]) + playAt(next) } MusicBridge.PlaybackMode.SEQUENTIAL -> if (index + 1 < size) { - index++ - playTrack(queue[index]) + playAt(index + 1) } } } + private const val HOT_KEYWORD = "热歌" + private fun post(block: () -> Unit) { mc.execute(block) } diff --git a/src/main/kotlin/top/fpsmaster/notification/NotificationManager.kt b/src/main/kotlin/top/fpsmaster/notification/NotificationManager.kt index de63691..f64fce0 100644 --- a/src/main/kotlin/top/fpsmaster/notification/NotificationManager.kt +++ b/src/main/kotlin/top/fpsmaster/notification/NotificationManager.kt @@ -10,6 +10,7 @@ import net.minecraft.client.gui.GuiGraphics /*import top.fpsmaster.compat.GuiGraphics*/ //?} import top.fpsmaster.mc +import top.fpsmaster.screenCompat import top.fpsmaster.module.impl.auxiliary.ClientSettings import top.fpsmaster.prism.input.FrameInput import top.fpsmaster.prism.overlay.NotificationCenter @@ -33,8 +34,18 @@ object NotificationManager { center.add(title, description, type, durationSeconds) } + /** Painted by [top.fpsmaster.ui.kit.ToolkitScreen] on top of Prism screens. */ + fun paint(ui: UiFrame) { + center.paint(ui) + } + @JvmStatic fun render(guiGraphics: GuiGraphics) { + // A Prism screen paints the notifications itself, above its own content; drawing them here + // as well would show them twice (once hidden under the screen's veil). + if (mc.screenCompat is top.fpsmaster.ui.kit.ToolkitScreen) { + return + } val width = mc.window.guiScaledWidth.toFloat() val height = mc.window.guiScaledHeight.toFloat() val host = NovaHost(NovaCanvas(guiGraphics, mc.font), input, mc.font, width, height) diff --git a/src/main/kotlin/top/fpsmaster/replay/ClipRing.kt b/src/main/kotlin/top/fpsmaster/replay/ClipRing.kt new file mode 100644 index 0000000..54ed63d --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/replay/ClipRing.kt @@ -0,0 +1,132 @@ +package top.fpsmaster.replay + +/** + * The last few minutes of a session, held in memory as segments so that "save the last N minutes" + * yields a recording that opens like any other. + * + * A packet stream cannot start at an arbitrary point: the world it describes was built by packets + * that have since been evicted. So every segment opens with a keyframe — the session packets and a + * terrain snapshot, exactly what [ReplayRecorder] writes when a recording starts mid-session — and a + * clip always begins at a segment boundary. The oldest segment is only dropped once the next one has + * a complete keyframe, so there is always a playable start. + * + * Memory is bounded twice: by time (segments wholly older than the window go) and by [maxBytes] + * (oldest segments go first, even inside the window). Called from the network thread (packets) and + * the client thread (samples, keyframes, saving); every method takes the lock. + */ +class ClipRing(private val maxBytes: Long) { + + class Segment internal constructor(val startMillis: Int) { + internal val keyframe = ArrayList() + internal val records = ArrayList() + internal var bytes = 0L + + /** False while the terrain snapshot is still being taken over several ticks. */ + var keyframeDone = false + internal set + } + + /** What a save writes: the first segment's keyframe at time 0, then every record, rebased. */ + class Clip(val baseMillis: Int, val keyframe: List, val records: List) { + val durationMillis: Int + get() = records.lastOrNull()?.let { it.millis - baseMillis }?.coerceAtLeast(0) ?: 0 + } + + private val segments = ArrayDeque() + + var totalBytes: Long = 0L + private set + + val segmentCount: Int + @Synchronized get() = segments.size + + /** Whether the newest segment alone has used up the budget, so a new keyframe is due early. */ + val newestOverBudget: Boolean + @Synchronized get() = (segments.lastOrNull()?.bytes ?: 0L) * 2 > maxBytes + + /** Starts a segment whose keyframe begins with [bootstrap]; terrain follows via [addKeyframe]. */ + @Synchronized + fun beginSegment(millis: Int, bootstrap: Collection): Segment { + val segment = Segment(millis) + bootstrap.forEach { add(segment, it) } + segments.addLast(segment) + return segment + } + + @Synchronized + fun addKeyframe(segment: Segment, payload: ByteArray) { + if (segments.contains(segment)) { + add(segment, payload) + } + } + + @Synchronized + fun finishKeyframe(segment: Segment) { + segment.keyframeDone = true + } + + /** Appends to the newest segment. Dropped when no segment has started yet. */ + @Synchronized + fun add(record: ReplayRecord) { + val segment = segments.lastOrNull() ?: return + segment.records.add(record) + val size = (record.payload?.size ?: 0) + RECORD_OVERHEAD + segment.bytes += size + totalBytes += size + } + + /** Drops segments that fell out of [windowMillis] or over the byte budget. */ + @Synchronized + fun trim(nowMillis: Int, windowMillis: Int) { + while (segments.size > 1) { + val next = segments.elementAt(1) + if (!next.keyframeDone) { + return + } + val outOfWindow = next.startMillis <= nowMillis - windowMillis + if (!outOfWindow && totalBytes <= maxBytes) { + return + } + totalBytes -= segments.removeFirst().bytes + } + } + + /** A playable copy of what is buffered, or null when no segment has a complete keyframe yet. */ + @Synchronized + fun clip(): Clip? { + val first = segments.firstOrNull { it.keyframeDone } ?: return null + val records = ArrayList() + var include = false + for (segment in segments) { + if (segment === first) { + include = true + } + if (include) { + records.addAll(segment.records) + } + } + return Clip(first.startMillis, ArrayList(first.keyframe), records) + } + + @Synchronized + fun clear() { + segments.clear() + totalBytes = 0L + } + + private fun add(segment: Segment, payload: ByteArray) { + segment.keyframe.add(payload) + val size = payload.size + RECORD_OVERHEAD + segment.bytes += size + totalBytes += size + } + + companion object { + /** Rough per-record cost on the heap beyond the payload: object header, fields, list slot. */ + const val RECORD_OVERHEAD: Int = 64 + + /** Re-stamps [record] relative to a clip that starts at [baseMillis]. */ + fun rebase(record: ReplayRecord, baseMillis: Int): ReplayRecord = + record.withMillis((record.millis - baseMillis).coerceAtLeast(0)) + } +} diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayClipBuffer.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayClipBuffer.kt new file mode 100644 index 0000000..a4c456e --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayClipBuffer.kt @@ -0,0 +1,164 @@ +package top.fpsmaster.replay + +import top.fpsmaster.logger +import top.fpsmaster.mc +import top.fpsmaster.replay.adapter.ReplayPacketAdapter +import top.fpsmaster.replay.adapter.ReplayWorldAdapter +import java.io.File +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean + +/** + * "Save the last N minutes": a rolling [ClipRing] fed from the same capture point as + * [ReplayRecorder], written out as an ordinary `.novareplay` on demand. + * + * Nothing touches the disk until a save, and the save itself runs on its own thread from an + * immutable copy of the buffer, so neither buffering nor saving can stall the render thread. + * Keyframe terrain snapshots are taken a few chunks per tick for the same reason: snapshotting the + * whole radius at once costs tens of milliseconds, once a minute. + */ +object ReplayClipBuffer { + + /** How often a new keyframe (and so a possible clip start) is taken. */ + private const val SEGMENT_MILLIS = 60_000 + + private const val CHUNKS_PER_TICK = 12 + + private const val EQUIPMENT_INTERVAL_MILLIS = 500L + + /** At most an eighth of the heap, and never more than 256 MB, however long the window. */ + private val MAX_BYTES: Long = (Runtime.getRuntime().maxMemory() / 8).coerceIn(32L shl 20, 256L shl 20) + + @Volatile + private var ring: ClipRing? = null + + @Volatile + private var epoch = 0L + + private var keyframeSegment: ClipRing.Segment? = null + private val pendingChunks = ArrayDeque>() + private var lastSegmentStart = 0 + private var lastEquipment = 0L + private val saving = AtomicBoolean(false) + + enum class SaveResult { STARTED, NOTHING_BUFFERED, BUSY } + + val isBuffering: Boolean + get() = ring != null + + /** Network thread. */ + fun onPacket(payload: ByteArray) { + ring?.add(ReplayRecord.packet(now(), payload)) + } + + /** Client thread, once per tick while in a live world with buffering enabled. */ + fun tick(windowMillis: Int) { + val active = ring ?: start() + val now = now() + continueKeyframe(active) + if (keyframeSegment == null && + (active.segmentCount == 0 || now - lastSegmentStart >= SEGMENT_MILLIS || active.newestOverBudget) + ) { + beginSegment(active, now) + } + ReplayWorldAdapter.sampleLocalPlayer(now)?.let { active.add(it) } + val wall = System.currentTimeMillis() + if (wall - lastEquipment >= EQUIPMENT_INTERVAL_MILLIS) { + lastEquipment = wall + val registries = ReplayWorldAdapter.liveRegistries() + for ((slot, packet) in ReplayWorldAdapter.localEquipment()) { + val payload = ReplayPacketAdapter.encode(packet, registries) ?: continue + active.add(ReplayRecord.equipment(now, slot, payload)) + } + } + active.trim(now, windowMillis) + } + + /** Client thread. Drops everything buffered: leaving the world, or buffering switched off. */ + fun stop() { + ring?.clear() + ring = null + keyframeSegment = null + pendingChunks.clear() + } + + /** + * Client thread. Writes the buffered clip to [directory] on a background thread; [onDone] runs + * on the client thread with the file, or null when writing failed. + */ + fun save(directory: File, onDone: (File?) -> Unit): SaveResult { + val clip = ring?.clip() ?: return SaveResult.NOTHING_BUFFERED + if (clip.records.isEmpty()) { + return SaveResult.NOTHING_BUFFERED + } + if (!saving.compareAndSet(false, true)) { + return SaveResult.BUSY + } + val profile = RecordingProfile( + recorderName = ReplayWorldAdapter.localName(), + recorderId = ReplayWorldAdapter.localId(), + dimension = ReplayWorldAdapter.currentDimension(), + serverAddress = ReplayWorldAdapter.currentServerAddress(), + positionSampleHz = ReplayRecorder.POSITION_SAMPLE_HZ, + capturesEquipment = true, + ) + val startMillis = epoch + clip.baseMillis + val version = ReplayPacketAdapter.minecraftVersion + val worker = Thread({ + val target = File(directory, "clip_${ReplayRecorder.formatName(Date())}${ReplayRecorder.EXTENSION}") + val written = try { + directory.mkdirs() + NovaReplayFile.openForWrite(target, version, startMillis, profile).use { writer -> + clip.keyframe.forEach { writer.write(ReplayRecord.snapshotPacket(0, it)) } + clip.records.forEach { writer.write(ClipRing.rebase(it, clip.baseMillis)) } + } + logger.info("[replay] saved ${clip.durationMillis / 1000}s clip to ${target.name}") + target + } catch (failure: Exception) { + logger.warn("[replay] could not save clip to $target", failure) + target.delete() + null + } finally { + saving.set(false) + } + mc.execute { onDone(written) } + }, "nova-replay-clip-writer") + worker.isDaemon = true + worker.start() + return SaveResult.STARTED + } + + private fun start(): ClipRing { + epoch = System.currentTimeMillis() + lastSegmentStart = 0 + lastEquipment = 0L + return ClipRing(MAX_BYTES).also { ring = it } + } + + private fun beginSegment(active: ClipRing, now: Int) { + keyframeSegment = active.beginSegment(now, ReplayRecorder.bootstrapPackets()) + lastSegmentStart = now + pendingChunks.clear() + pendingChunks.addAll(ReplayWorldAdapter.snapshotChunkPositions()) + } + + private fun continueKeyframe(active: ClipRing) { + val segment = keyframeSegment ?: return + val registries = ReplayWorldAdapter.liveRegistries() + repeat(CHUNKS_PER_TICK) { + val position = pendingChunks.removeFirstOrNull() + if (position == null) { + active.finishKeyframe(segment) + keyframeSegment = null + return + } + val packet = ReplayWorldAdapter.snapshotChunk(position.first, position.second) ?: return@repeat + ReplayPacketAdapter.encode(packet, registries)?.let { active.addKeyframe(segment, it) } + } + } + + private fun now(): Int { + val elapsed = System.currentTimeMillis() - epoch + return if (elapsed >= Int.MAX_VALUE) Int.MAX_VALUE else elapsed.toInt() + } +} diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index 0106319..0eb19c9 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -26,6 +26,8 @@ import java.io.File class ReplayPlayback private constructor( val header: NovaReplayFile.Header, private val records: List, + /** The file held more than [maxLoadedBytes]; only its beginning is playable. */ + val truncated: Boolean, ) { /** Position on the recording's own timeline. */ @@ -222,22 +224,25 @@ class ReplayPlayback private constructor( fun load(file: File): ReplayPlayback { val reader = NovaReplayFile.openForRead(file, ReplayPacketAdapter.minecraftVersion) val records = ArrayList() + val limit = maxLoadedBytes() + var truncated = false reader.use { var bytes = 0L while (true) { val record = it.read() ?: break records.add(record) - bytes += (record.payload?.size ?: 0).toLong() - if (bytes > MAX_LOADED_BYTES) { + bytes += (record.payload?.size ?: 0).toLong() + ClipRing.RECORD_OVERHEAD + if (bytes > limit) { + truncated = true logger.warn( - "[replay] ${file.name} is larger than ${MAX_LOADED_BYTES shr 20}MB;" + + "[replay] ${file.name} is larger than ${limit shr 20}MB;" + " playing back the first ${records.size} records" ) break } } } - return ReplayPlayback(reader.header, records) + return ReplayPlayback(reader.header, records, truncated) } /** Header only, for listing recordings without reading them. */ @@ -247,7 +252,10 @@ class ReplayPlayback private constructor( null } - /** Ceiling on how much of a recording is held at once. */ - private const val MAX_LOADED_BYTES = 512L shl 20 + /** + * Ceiling on how much of a recording is held at once: a quarter of the heap, at most 512 MB. + * A fixed 512 MB on the common 2 GB default heap left too little for the world itself. + */ + fun maxLoadedBytes(): Long = (Runtime.getRuntime().maxMemory() / 4).coerceIn(64L shl 20, 512L shl 20) } } diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayRecord.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayRecord.kt index 44ff3e5..440c109 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayRecord.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayRecord.kt @@ -22,6 +22,10 @@ class ReplayRecord private constructor( val isPacket: Boolean get() = type == NovaReplayFile.TYPE_PACKET || type == NovaReplayFile.TYPE_SNAPSHOT_PACKET + /** The same event stamped at [newMillis]; used when a buffered clip is rebased to start at 0. */ + fun withMillis(newMillis: Int): ReplayRecord = + if (newMillis == millis) this else ReplayRecord(type, newMillis, payload, slot, x, y, z, yaw, pitch, flags) + companion object { fun packet(millis: Int, payload: ByteArray): ReplayRecord = ReplayRecord(NovaReplayFile.TYPE_PACKET, millis, payload, -1, 0.0, 0.0, 0.0, 0f, 0f, 0) diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayRecorder.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayRecorder.kt index f431794..2fadf19 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayRecorder.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayRecorder.kt @@ -1,5 +1,6 @@ package top.fpsmaster.replay +import net.minecraft.core.RegistryAccess import net.minecraft.network.protocol.Packet import top.fpsmaster.logger import top.fpsmaster.replay.adapter.ReplayPacketAdapter @@ -8,6 +9,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.concurrent.ConcurrentLinkedQueue /** * Turns a live connection into a `.novareplay` file. @@ -34,6 +36,13 @@ object ReplayRecorder { @Volatile private var queue: ReplayWriteQueue? = null + /** + * Queues stopped without waiting whose writer may still be draining. Kept so shutdown can wait + * for them: the writer is a daemon thread, and a stop shortly before quitting (auto-record ends + * on leaving the world) would otherwise lose the recording's tail when the JVM exits. + */ + private val draining = ConcurrentLinkedQueue() + @Volatile var file: File? = null private set @@ -57,28 +66,42 @@ object ReplayRecorder { get() = if (startMillis == 0L) 0L else System.currentTimeMillis() - startMillis /** - * Network thread. Called once per decoded clientbound packet, before it is handled. + * Network thread. Called once per decoded clientbound packet, before it is handled — whether or + * not anything is recording, because the session packets ([bootstrap]) go past exactly once, at + * login, long before a recording can be started from inside the world. When nothing is capturing + * only those few packets are encoded. + * + * [registries] comes from the receiving connection's listener: at login `mc.player` is still + * null, so `mc.connection` is too, and on 1.20.5+ nothing can be encoded without registries. * * Bundles are taken apart here rather than stored whole: the protocol codec has no entry for a * bundle, and consecutive records replay in the same tick anyway. */ - fun onClientboundPacket(packet: Packet<*>) { - val registries = ReplayWorldAdapter.liveRegistries() + fun onClientboundPacket(packet: Packet<*>, registries: RegistryAccess?) { if (ReplayWorldAdapter.isSessionStart(packet)) { - bootstrap.clear() + synchronized(bootstrap) { bootstrap.clear() } } + val active = queue + val capturing = active != null || ReplayClipBuffer.isBuffering for (single in ReplayPacketAdapter.expand(packet)) { + val isBootstrap = ReplayWorldAdapter.isBootstrapPacket(single) + if (!capturing && !isBootstrap) { + continue + } val payload = ReplayPacketAdapter.encode(single, registries) ?: continue - if (ReplayWorldAdapter.isBootstrapPacket(single)) { - bootstrap[single.javaClass.simpleName] = payload + if (isBootstrap) { + synchronized(bootstrap) { bootstrap[single.javaClass.simpleName] = payload } } - val active = queue ?: continue - if (!active.offer(ReplayRecord.packet(millisNow(), payload))) { + ReplayClipBuffer.onPacket(payload) + if (active != null && !active.offer(ReplayRecord.packet(millisNow(), payload))) { reportStop(active) } } } + /** The session packets seen since the last login, in arrival order. */ + fun bootstrapPackets(): List = synchronized(bootstrap) { ArrayList(bootstrap.values) } + /** Client thread, once per tick: the local player's own state, which no packet carries. */ fun onClientTick() { val active = queue ?: return @@ -109,7 +132,7 @@ object ReplayRecorder { logger.warn("[replay] could not create $directory") return null } - val target = File(directory, "${NAME_FORMAT.format(Date())}$EXTENSION") + val target = File(directory, "${formatName(Date())}$EXTENSION") startMillis = System.currentTimeMillis() lastEquipment = 0L val recordingProfile = RecordingProfile( @@ -140,11 +163,25 @@ object ReplayRecorder { return target } - /** Client thread. Drains what is queued, closes the file and returns it. */ - fun stop(): File? { - val active = queue ?: return null + /** + * Client thread. Stops accepting records; the writer thread drains what is queued and closes the + * file on its own, so the render thread never waits on the disk. Only [Client.shutdown][top.fpsmaster.Client.shutdown] + * passes [wait], since the daemon writer would otherwise die with the JVM mid-file; it also waits + * for writers of recordings stopped earlier that are still draining. + */ + fun stop(wait: Boolean = false): File? { + val active = queue + if (active == null) { + if (wait) awaitDraining() + return null + } queue = null - active.finish() + if (wait) { + active.finish() + awaitDraining() + } else { + finishAsync(active) + } val written = active.recordsAccepted val target = file logger.info( @@ -161,7 +198,7 @@ object ReplayRecorder { * of a recording applies them first however far the timeline is scrubbed. */ private fun writeOpening(active: ReplayWriteQueue) { - bootstrap.values.forEach { payload -> + bootstrapPackets().forEach { payload -> active.offer(ReplayRecord.snapshotPacket(0, payload)) } val registries = ReplayWorldAdapter.liveRegistries() @@ -200,10 +237,26 @@ object ReplayRecorder { " recording stopped so the file ends where it is still correct" ) } - active.finish() + // Network or client thread: never wait for the writer here. + finishAsync(active) + } + + private fun finishAsync(active: ReplayWriteQueue) { + draining.removeIf { !it.isWriterAlive } + draining.add(active) + active.finishAsync() + } + + private fun awaitDraining() { + while (true) { + draining.poll()?.finish() ?: return + } } const val EXTENSION: String = ".novareplay" private val NAME_FORMAT = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT) + + /** SimpleDateFormat is not thread-safe, and clips are named on their writer thread. */ + fun formatName(date: Date): String = synchronized(NAME_FORMAT) { NAME_FORMAT.format(date) } } diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayWriteQueue.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayWriteQueue.kt index 0b04bff..4390f0d 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayWriteQueue.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayWriteQueue.kt @@ -47,6 +47,8 @@ class ReplayWriteQueue( private set private var lastFlush = clock() + + @Volatile private var thread: Thread? = null enum class StopReason { QUEUE_FULL, WRITE_FAILED, REQUESTED } @@ -54,6 +56,10 @@ class ReplayWriteQueue( val isAccepting: Boolean get() = accepting + /** True while a writer thread is still draining or closing the file. */ + val isWriterAlive: Boolean + get() = thread?.isAlive == true + /** * Network thread. Returns false once recording has ended — the first caller to find the queue * full is the one that ends it. @@ -86,6 +92,17 @@ class ReplayWriteQueue( worker.start() } + /** + * Ends the recording without waiting: the writer thread drains what is queued and closes the + * file by itself. Falls back to draining here only when no writer thread was ever started. + */ + fun finishAsync() { + stop(StopReason.REQUESTED) + if (thread == null) { + drainAndClose() + } + } + /** Ends the recording and waits for the writer to drain and close the file. */ fun finish(joinMillis: Long = 5000L) { stop(StopReason.REQUESTED) diff --git a/src/main/kotlin/top/fpsmaster/replay/adapter/ReplayWorldAdapter.kt b/src/main/kotlin/top/fpsmaster/replay/adapter/ReplayWorldAdapter.kt index 2893f4d..bde2d22 100644 --- a/src/main/kotlin/top/fpsmaster/replay/adapter/ReplayWorldAdapter.kt +++ b/src/main/kotlin/top/fpsmaster/replay/adapter/ReplayWorldAdapter.kt @@ -126,34 +126,42 @@ object ReplayWorldAdapter { * on nothing without this. Entities are not snapshotted: their spawn packets have changed shape * in most of the releases Nova targets, and they reappear as the server re-sends them. */ - fun snapshotChunks(radius: Int = SNAPSHOT_RADIUS): List> { + fun snapshotChunks(radius: Int = SNAPSHOT_RADIUS): List> = + snapshotChunkPositions(radius).mapNotNull { snapshotChunk(it.first, it.second) } + + /** Loaded chunk positions around the local player, for snapshots taken a few chunks per tick. */ + fun snapshotChunkPositions(radius: Int = SNAPSHOT_RADIUS): List> { val level = mc.level ?: return emptyList() val player = mc.player ?: return emptyList() - val lighting = level.lightEngine val centreX = player.blockX shr 4 val centreZ = player.blockZ shr 4 - val packets = ArrayList>() + val positions = ArrayList>() for (x in centreX - radius..centreX + radius) { for (z in centreZ - radius..centreZ + radius) { - if (!level.chunkSource.hasChunk(x, z)) { - continue - } - val chunk = level.getChunk(x, z) - val packet = try { - //? if >=1.20 { - ClientboundLevelChunkWithLightPacket(chunk, lighting, null as BitSet?, null as BitSet?) - //?} else { - /*ClientboundLevelChunkWithLightPacket(chunk, lighting, null as BitSet?, null as BitSet?, false) - *///?} - } catch (unavailable: Throwable) { - null - } - if (packet != null) { - packets.add(packet) + if (level.chunkSource.hasChunk(x, z)) { + positions.add(x to z) } } } - return packets + return positions + } + + /** One chunk as the packet that would have delivered it, or null once it is no longer loaded. */ + fun snapshotChunk(x: Int, z: Int): Packet<*>? { + val level = mc.level ?: return null + if (!level.chunkSource.hasChunk(x, z)) { + return null + } + return try { + val chunk = level.getChunk(x, z) + //? if >=1.20 { + ClientboundLevelChunkWithLightPacket(chunk, level.lightEngine, null as BitSet?, null as BitSet?) + //?} else { + /*ClientboundLevelChunkWithLightPacket(chunk, level.lightEngine, null as BitSet?, null as BitSet?, false) + *///?} + } catch (unavailable: Throwable) { + null + } } /** diff --git a/src/main/kotlin/top/fpsmaster/telemetry/TelemetryReporter.kt b/src/main/kotlin/top/fpsmaster/telemetry/TelemetryReporter.kt index 7111f53..eeb148a 100644 --- a/src/main/kotlin/top/fpsmaster/telemetry/TelemetryReporter.kt +++ b/src/main/kotlin/top/fpsmaster/telemetry/TelemetryReporter.kt @@ -27,6 +27,9 @@ object TelemetryReporter { private const val CLIENT_KIND = "NOVA" private val CLIENT_VERSION = top.fpsmaster.Client.VERSION private const val MAX_SAMPLED_PLAYERS = 8 + private const val FIRST_BACKOFF_MS = 30_000L + private const val MAX_BACKOFF_MS = 15 * 60_000L + private const val SHUTDOWN_WAIT_MS = 1_500L // Shared with the other clients so the same account maps to the same hash across editions // (enables unique-user counting) without exposing raw account identity. @@ -53,6 +56,21 @@ object TelemetryReporter { private var lastPresenceAt = 0L private var lastPresenceServerHash: String? = null + /** Server address -> its hash, so the per-frame tick does not re-hash an unchanged address. */ + private var hashedServerAddress: String? = null + private var hashedServerId: String = "" + + /** + * After a failed send nothing goes out before this. Failures used to leave the "last sent" + * timestamps untouched, so the next frame retried: every client hammered a struggling backend at + * frame rate. Backoff starts at 30 s and doubles to 15 min; 429 waits the maximum. + */ + @Volatile + private var retryAt = 0L + + @Volatile + private var backoffMs = 0L + fun configure(enabled: Boolean, instanceId: String?) { anonymousDataEnabled = enabled telemetryInstanceId = instanceId?.trim().takeUnless { it.isNullOrEmpty() } @@ -84,20 +102,25 @@ object TelemetryReporter { return } - val presenceSnapshot = capturePresenceSnapshot() + if (now < retryAt) { + multiplayerActive = true + return + } + // Runs every frame: decide from cheap state first, build the snapshot only when it is sent. + val serverIdHash = currentServerIdHash() val shouldSendHeartbeat = !multiplayerActive || now - lastHeartbeatAt >= HEARTBEAT_INTERVAL_MS - val shouldSendPresence = presenceSnapshot != null && ( + val shouldSendPresence = serverIdHash != null && ( !multiplayerActive || now - lastPresenceAt >= PRESENCE_INTERVAL_MS || - presenceSnapshot.serverIdHash != lastPresenceServerHash + serverIdHash != lastPresenceServerHash ) multiplayerActive = true if (shouldSendHeartbeat) { submitHeartbeat(now) } - if (shouldSendPresence) { - submitPresence(now, presenceSnapshot) + if (shouldSendPresence && !presenceInFlight.get()) { + capturePresenceSnapshot(serverIdHash!!)?.let { submitPresence(now, it) } } } @@ -108,6 +131,18 @@ object TelemetryReporter { clearState() } + private fun recordSuccess() { + backoffMs = 0L + retryAt = 0L + } + + private fun recordFailure(status: Int) { + backoffMs = if (status == 429) MAX_BACKOFF_MS + else if (backoffMs == 0L) FIRST_BACKOFF_MS + else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + retryAt = System.currentTimeMillis() + backoffMs + } + private fun submitHeartbeat(now: Long) { if (!heartbeatInFlight.compareAndSet(false, true)) { return @@ -117,10 +152,13 @@ object TelemetryReporter { sendJsonAsync(TELEMETRY_URL, payload) .whenComplete { response, exception -> if (exception != null) { + recordFailure(0) logger.warn("Nova heartbeat failed: ${exception.message}") } else if (response.statusCode() in 200..299) { lastHeartbeatAt = now + recordSuccess() } else { + recordFailure(response.statusCode()) logger.warn("Nova heartbeat failed with status ${response.statusCode()}") } heartbeatInFlight.set(false) @@ -136,11 +174,14 @@ object TelemetryReporter { sendJsonAsync(PRESENCE_URL, payload) .whenComplete { response, exception -> if (exception != null) { + recordFailure(0) logger.warn("Nova presence failed: ${exception.message}") } else if (response.statusCode() in 200..299) { lastPresenceAt = now lastPresenceServerHash = snapshot.serverIdHash + recordSuccess() } else { + recordFailure(response.statusCode()) logger.warn("Nova presence failed with status ${response.statusCode()}") } presenceInFlight.set(false) @@ -150,8 +191,9 @@ object TelemetryReporter { private fun submitOffline(blocking: Boolean) { val payload = buildOfflinePayload() if (blocking) { + // Game shutdown: give the backend a moment, never the 10 s + 20 s HTTP timeouts. runCatching { - httpClient.send(buildRequest(OFFLINE_URL, payload), HttpResponse.BodyHandlers.ofString()) + sendJsonAsync(OFFLINE_URL, payload).get(SHUTDOWN_WAIT_MS, java.util.concurrent.TimeUnit.MILLISECONDS) }.onFailure { exception -> logger.warn("Nova offline failed: ${exception.message}") } @@ -209,10 +251,17 @@ object TelemetryReporter { !mc.isLocalServer } - private fun capturePresenceSnapshot(): PresenceSnapshot? { - val serverData = mc.currentServer ?: return null - val rawServerAddress = serverData.ip.trim().takeIf { it.isNotEmpty() } ?: return null - val serverIdHash = sha256Hex(rawServerAddress.lowercase(Locale.ROOT)) + private fun currentServerIdHash(): String? { + val address = mc.currentServer?.ip?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (address != hashedServerAddress) { + hashedServerAddress = address + hashedServerId = sha256Hex(address.lowercase(Locale.ROOT)) + } + return hashedServerId + } + + private fun capturePresenceSnapshot(serverIdHash: String): PresenceSnapshot? { + val rawServerAddress = hashedServerAddress ?: return null val playerInfos = mc.connection?.onlinePlayers ?: emptyList() val sampledPlayersPayload = buildSampledPlayersPayload(playerInfos, serverIdHash) val payloadJson = JsonObject().apply { @@ -288,7 +337,7 @@ object TelemetryReporter { private fun sha256Hex(value: String): String { val hash = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(StandardCharsets.UTF_8)) - return hash.joinToString("") { "%02x".format(it) } + return java.util.HexFormat.of().formatHex(hash) } private fun shortenHash(value: String, length: Int): String { diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeBackgroundScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeBackgroundScreen.kt index 15b6313..7d5d0e7 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeBackgroundScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeBackgroundScreen.kt @@ -21,8 +21,9 @@ import top.fpsmaster.prism.screen.SharedBackgrounds import top.fpsmaster.prism.theme.Argb import top.fpsmaster.prism.widget.UiFrame import java.awt.Color -import java.awt.FileDialog -import java.awt.Frame +import top.fpsmaster.notification.NotificationManager +import top.fpsmaster.prism.overlay.NotificationCenter +import top.fpsmaster.ui.kit.NativeDialogs import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -59,20 +60,31 @@ class NativeBackgroundScreen( ConfigManager.setBackground(id) } + // Called from Prism's draw (inside Screen.render): the dialog runs on a worker thread and the + // copy happens in the render-thread callback, so nothing here can throw into the render stack. override fun pickCustom() { - val dialog = FileDialog(null as Frame?, Language.get("backgroundselector.filedialog.title"), FileDialog.LOAD) - dialog.setFilenameFilter { _, name -> - val lower = name.lowercase() - lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") + NativeDialogs.pickFiles( + Language.get("backgroundselector.filedialog.title"), + listOf("*.png", "*.jpg", "*.jpeg"), + Language.get("backgroundselector.filedialog.filter"), + multiple = false + ) { selected -> + val source = selected.firstOrNull() ?: return@pickFiles + try { + val dest = MainMenuBackgroundRenderer.customBackgroundFile() + Files.createDirectories(dest.parent) + Files.copy(source.toPath(), dest, StandardCopyOption.REPLACE_EXISTING) + ConfigManager.setBackground("custom") + } catch (exception: Exception) { + top.fpsmaster.logger.warn("Failed to import custom background {}", source, exception) + NotificationManager.add( + Language.get("backgroundselector.filedialog.title"), + Language.get("backgroundselector.import.failed"), + 3f, + NotificationCenter.Type.ERROR + ) + } } - dialog.isVisible = true - val file = dialog.file ?: return - val dir = dialog.directory ?: return - val source = java.io.File(dir, file).toPath() - val dest = MainMenuBackgroundRenderer.customBackgroundFile() - Files.createDirectories(dest.parent) - Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING) - ConfigManager.setBackground("custom") } override fun hasCustom(): Boolean = MainMenuBackgroundRenderer.hasCustomImage() diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeConfigProfilesScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeConfigProfilesScreen.kt index 4634b59..d92e007 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeConfigProfilesScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeConfigProfilesScreen.kt @@ -11,10 +11,13 @@ import top.fpsmaster.translation.Language import top.fpsmaster.ui.kit.ToolkitScreen import top.fpsmaster.prism.screen.ConfigProfilesBridge import top.fpsmaster.prism.screen.SharedConfigProfiles +import top.fpsmaster.prism.theme.Metrics +import top.fpsmaster.prism.widget.Chrome import top.fpsmaster.prism.widget.UiFrame -import java.awt.FileDialog -import java.awt.Frame -import java.nio.file.Paths +import top.fpsmaster.logger +import top.fpsmaster.notification.NotificationManager +import top.fpsmaster.prism.overlay.NotificationCenter +import top.fpsmaster.ui.kit.NativeDialogs class NativeConfigProfilesScreen( private val parent: net.minecraft.client.gui.screens.Screen? @@ -22,10 +25,57 @@ class NativeConfigProfilesScreen( private val gui = SharedConfigProfiles() private val bridge = NovaProfilesBridge() + /** Until when the rollback button is armed: it needs a second click within 3 s. */ + private var rollbackArmedUntil = 0L + override fun renderUi(ui: UiFrame) { if (gui.draw(ui, bridge)) { mc.setScreenCompat(parent) + return } + drawRollback(ui) + } + + /** + * "Roll back to the last backup", under the profiles panel. Prism's profile screen has no slot + * for it, so it sits just below the panel, whose size mirrors SharedConfigProfiles.draw. + */ + private fun drawRollback(ui: UiFrame) { + val gw = ui.host().width() + val gh = ui.host().height() + val ph = minOf(250f, maxOf(180f, gh - 32f)) + val panelBottom = (gh - ph) / 2f + ph + val h = Metrics.BTN_H + val y = panelBottom + 4f + if (y + h > gh) { + return + } + val armed = System.currentTimeMillis() < rollbackArmedUntil + val label = Language.get(if (armed) "configbackup.rollback.confirm" else "configbackup.rollback") + val w = ui.font(11).measure(label) + 16f + if (!Chrome.button(ui, (gw - w) / 2f, y, w, h, label, + if (armed) Chrome.ButtonStyle.DANGER else Chrome.ButtonStyle.GHOST)) { + return + } + if (!armed) { + rollbackArmedUntil = System.currentTimeMillis() + 3000L + return + } + rollbackArmedUntil = 0L + val (message, ok) = try { + Language.get("configbackup.rollback.done").format(ConfigManager.rollbackActive()) to true + } catch (failure: CommandExecutionException) { + failure.message.orEmpty() to false + } catch (failure: Exception) { + logger.warn("Config rollback failed", failure) + Language.get("configbackup.rollback.failed") to false + } + NotificationManager.add( + Language.get("configbackup.title"), + message, + 3f, + if (ok) NotificationCenter.Type.SUCCESS else NotificationCenter.Type.ERROR + ) } override fun handleEscape(): Boolean { @@ -52,56 +102,73 @@ class NativeConfigProfilesScreen( override fun isDefault(name: String): Boolean = ConfigManager.isDefaultProfile(name) - override fun load(name: String): String = run("configprofiles.status.loaded", "configprofiles.status.load_failed") { + override fun load(name: String): ConfigProfilesBridge.Result = run("configprofiles.status.loaded", "configprofiles.status.load_failed") { ConfigManager.loadProfile(name) name } - override fun delete(name: String): String = run("configprofiles.status.deleted", "configprofiles.status.delete_failed") { + override fun delete(name: String): ConfigProfilesBridge.Result = run("configprofiles.status.deleted", "configprofiles.status.delete_failed") { ConfigManager.delete(name) name } - override fun rename(from: String, to: String): String = + override fun rename(from: String, to: String): ConfigProfilesBridge.Result = run("configprofiles.status.renamed", "configprofiles.status.rename_failed") { ConfigManager.rename(from, to) } - override fun create(name: String): String = run("configprofiles.status.ready", "configprofiles.status.load_failed") { + override fun create(name: String): ConfigProfilesBridge.Result = run("configprofiles.status.ready", "configprofiles.status.load_failed") { ConfigManager.create(name) name } - override fun exportActive(): String = run("configprofiles.status.exported", "configprofiles.status.export_failed") { + override fun exportActive(): ConfigProfilesBridge.Result = run("configprofiles.status.exported", "configprofiles.status.export_failed") { ConfigManager.exportProfile(ConfigManager.activeName()) } - override fun importFile(): String = run("configprofiles.status.imported", "configprofiles.status.import_failed") { - val dialog = FileDialog(null as Frame?, Language.get("configprofiles.filedialog.import"), FileDialog.LOAD) - dialog.isVisible = true - val file = dialog.file ?: throw CommandExecutionException("cancelled") - val dir = dialog.directory ?: "" - ConfigManager.importProfile(Paths.get(dir, file).toString()) + // The picker runs off the render thread (see NativeDialogs), so the import outcome arrives + // after this call returned; it is reported as a notification over the screen instead. + override fun importFile(): ConfigProfilesBridge.Result { + val started = NativeDialogs.pickFiles( + Language.get("configprofiles.filedialog.import"), + listOf("*.json"), + "JSON", + multiple = false + ) { selected -> + val file = selected.firstOrNull() ?: return@pickFiles + val result = run("configprofiles.status.imported", "configprofiles.status.import_failed") { + ConfigManager.importProfile(file.toString()) + } + NotificationManager.add( + Language.get("configprofiles.importfile"), + result.message, + 3f, + if (result.ok) NotificationCenter.Type.SUCCESS else NotificationCenter.Type.ERROR + ) + } + return if (started) ConfigProfilesBridge.Result.ok(Language.get("configprofiles.status.picking")) + else ConfigProfilesBridge.Result.failed(Language.get("configprofiles.status.picker_busy")) } - override fun resetAllOff(): String = run("configprofiles.status.alloff", "configprofiles.status.alloff_failed") { + override fun resetAllOff(): ConfigProfilesBridge.Result = run("configprofiles.status.alloff", "configprofiles.status.alloff_failed") { ConfigManager.resetActiveToAllOff() ConfigManager.activeName() } - private fun run(okKey: String, failKey: String, block: () -> String): String { + private fun run(okKey: String, failKey: String, block: () -> String): ConfigProfilesBridge.Result { return try { val arg = block() - try { + val message = try { String.format(Language.get(okKey), arg) } catch (_: Exception) { Language.get(okKey) } - } catch (cancelled: CommandExecutionException) { - if (cancelled.message == "cancelled") Language.get("configprofiles.status.ready") - else Language.get(failKey) + ": " + cancelled.message + ConfigProfilesBridge.Result.ok(message) + } catch (failure: CommandExecutionException) { + ConfigProfilesBridge.Result.failed(Language.get(failKey) + ": " + failure.message) } catch (err: Exception) { - Language.get(failKey) + logger.warn("Config profile operation failed", err) + ConfigProfilesBridge.Result.failed(Language.get(failKey)) } } } diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeCosmeticsScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeCosmeticsScreen.kt index 6ada506..6d6f661 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeCosmeticsScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeCosmeticsScreen.kt @@ -568,8 +568,16 @@ class NativeCosmeticsScreen(private val parent: Screen?) : ToolkitScreen(Compone // 否则表头一直显示扣款前的数。 FPSMasterApiClient.refreshProfileNow() status = Language.get("cosmetics.purchase.success") + } else if (result?.code == "ITEM_ALREADY_OWNED") { + // Bought earlier (another device, the website): the owned list here is stale. + CosmeticManager.refreshOwned() + status = result.serverText() ?: Language.get("cosmetics.purchase.failed") } else { - status = exception?.message ?: result?.message ?: Language.get("cosmetics.purchase.failed") + // The backend's message is already localized; local failures (no response, + // unparsable body) get our own wording instead of a raw exception string. + status = result?.serverText() + ?: if (exception != null || result?.statusCode == 0) Language.get("cosmetics.purchase.network") + else Language.get("cosmetics.purchase.failed") } } } diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeMainMenuScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeMainMenuScreen.kt index 89f6332..772e8f4 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeMainMenuScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeMainMenuScreen.kt @@ -30,8 +30,7 @@ import top.fpsmaster.prism.screen.SharedAccountOverlay import top.fpsmaster.prism.screen.SharedMainMenu import top.fpsmaster.prism.theme.Argb import top.fpsmaster.prism.widget.UiFrame -import java.awt.Desktop -import java.net.URI +import top.fpsmaster.ui.kit.NativeDialogs import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlin.concurrent.thread @@ -176,10 +175,13 @@ class NativeMainMenuScreen : ToolkitScreen(Component.literal("FPSMaster")) { ms.openUrl() } + // The browser flow has no device code. Prism's code box is the only clickable field in the + // dialog, so it carries the "copy sign-in link" fallback for when no browser opens. override fun copyMicrosoftCode() { - val code = ms.userCode - if (code.isNotEmpty()) { - mc.keyboardHandler.clipboard = code + val url = ms.verifyUrl + if (url.isNotEmpty()) { + mc.keyboardHandler.clipboard = url + ms.status = Language.get("mainmenu.account.ms.linkcopied") } } @@ -191,7 +193,8 @@ class NativeMainMenuScreen : ToolkitScreen(Component.literal("FPSMaster")) { ms.abort() } - override fun microsoftCode(): String = ms.userCode + override fun microsoftCode(): String = + if (ms.verifyUrl.isNotEmpty()) Language.get("mainmenu.account.ms.copylink") else "" override fun microsoftStatus(): String = ms.status override fun microsoftError(): String = ms.error override fun microsoftBusy(): Boolean = ms.busy @@ -302,7 +305,6 @@ class NativeMainMenuScreen : ToolkitScreen(Component.literal("FPSMaster")) { private inner class MicrosoftLoginState { @Volatile var busy = false - @Volatile var userCode = "" @Volatile var verifyUrl = "" @Volatile var status = "" @Volatile var error = "" @@ -343,7 +345,6 @@ class NativeMainMenuScreen : ToolkitScreen(Component.literal("FPSMaster")) { val gen = generation.incrementAndGet() cancelled.set(false) busy = true - userCode = "" verifyUrl = "" status = Language.get("mainmenu.account.ms.starting") error = "" @@ -358,11 +359,8 @@ class NativeMainMenuScreen : ToolkitScreen(Component.literal("FPSMaster")) { if (url.isBlank()) { return } - try { - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().browse(URI(url)) - } - } catch (_: Exception) { + if (!NativeDialogs.openUrl(url)) { + status = Language.get("mainmenu.account.ms.openfailed") } } diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeMusicScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeMusicScreen.kt index 4edef6e..cab94e9 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeMusicScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeMusicScreen.kt @@ -15,8 +15,7 @@ import top.fpsmaster.config.ConfigManager import top.fpsmaster.music.MusicSource import top.fpsmaster.music.Track import top.fpsmaster.ui.kit.NovaMusicTextures -import java.awt.FileDialog -import java.awt.Frame +import top.fpsmaster.ui.kit.NativeDialogs import java.io.File class NativeMusicScreen( @@ -82,21 +81,24 @@ class NativeMusicScreen( MusicBridge.TrackRow(track.name, track.artists, "0:00", false) } override fun importLocalMusic() { - Thread({ - val dialog = FileDialog(null as Frame?, "选择本地音乐", FileDialog.LOAD).apply { - isMultipleMode = true - filenameFilter = java.io.FilenameFilter { _, name -> isLocalAudio(name) } - isVisible = true + NativeDialogs.pickFiles( + Language.get("music.local.picker"), + listOf("*.mp3", "*.wav", "*.aiff", "*.aif"), + Language.get("music.local.picker.filter"), + multiple = true + ) { selected -> + if (selected.isNotEmpty()) { + local = selected.mapNotNull(::localTrack) } - val selected = dialog.files - dialog.dispose() - if (selected.isEmpty()) return@Thread - mc.execute { local = selected.mapNotNull(::localTrack) } - }, "FPSMaster-Music-Picker").apply { isDaemon = true }.start() + } } override fun playLocal(index: Int) = MusicController.playLocal(local, index) override fun listTitle(): String = MusicController.listTitle + override fun tracksState(): MusicBridge.ListState = MusicController.tracksState + override fun tracksError(): String = MusicController.tracksError + override fun playlistsState(): MusicBridge.ListState = MusicController.playlistsState + override fun playlistsError(): String = MusicController.playlistsError override fun search(query: String) = MusicController.search(query) override fun loadDiscover() = MusicController.loadDiscover() override fun loadPlaylists() = MusicController.loadPlaylists() @@ -128,7 +130,7 @@ class NativeMusicScreen( private fun localTrack(file: File): Track? { if (!file.isFile || !isLocalAudio(file.name)) return null return Track(MusicSource.NETEASE, file.toURI().toString(), name = file.nameWithoutExtension, - artists = "本地音乐", coverUrl = localCover(file)?.toURI()?.toString()) + artists = Language.get("music.local.artist"), coverUrl = localCover(file)?.toURI()?.toString()) } private fun localCover(audio: File): File? { diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeSignInScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeSignInScreen.kt index 5d0e969..4e6a135 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeSignInScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeSignInScreen.kt @@ -22,8 +22,7 @@ import top.fpsmaster.screenCompat import top.fpsmaster.setScreenCompat import top.fpsmaster.translation.Language import top.fpsmaster.ui.kit.ToolkitScreen -import java.awt.Desktop -import java.net.URI +import top.fpsmaster.ui.kit.NativeDialogs /** * FPSMaster 产品账号登录(不是 Minecraft 账号,那条在首页的账号胶囊里)。 @@ -38,7 +37,6 @@ class NativeSignInScreen( const val WEBSITE_URL = "https://fpsmaster.top/login" /** 后端 `banReason` 为空时的兜底原话(AuthService.loginInternal / CurrentUser)。 */ - const val BACKEND_DEFAULT_BAN_REASON = "account is banned" } private val gui = SharedSignIn() @@ -152,35 +150,21 @@ class NativeSignInScreen( } /** - * 后端和 [FPSMasterApiClient.parseResponse] 给的都是裸英文(`invalid credentials`、 - * `HTTP 401`、`Empty response (500)`),直接贴到界面上中文玩家看不懂, - * 所以按状态码分流到 lang 文件里的文案。 + * 后端失败响应带稳定的 `code`,`message` 已按 Accept-Language 本地化,直接显示即可; + * 封禁时 message 就是带原因的封禁提示。没有 code 的(网络失败、解析失败、Cloudflare + * 挡下来的页面、还没升级的后端)才按状态码落到 lang 文件里的本地文案。 */ - private fun localizeLoginError(result: ApiResult<*>): String = when { - result.statusCode == 0 -> Language.get("signin.failed.network") - result.statusCode == 401 -> Language.get("signin.failed.credentials") - // 后端对被封账号返回 403,message 就是封禁原因(AuthService.loginInternal)。 - // 并进 401 的话玩家看到的是「账号或密码不正确」,会反复改密码而不是来申诉。 - result.statusCode == 403 -> banMessage(result) - result.statusCode == 429 -> Language.get("signin.failed.throttled") - result.statusCode >= 500 -> Language.get("signin.failed.server") - else -> result.message.ifBlank { Language.get("signin.failed") } - } - - /** - * 403 的文案。 - * - * 只有后端按契约给出的原话才直接显示:Cloudflare 之类挡在前面时正文是 HTML, - * 解析失败后 message 会是 `Parse error: ...`,贴上去就是一句英文技术黑话。后端自己 - * 那句兜底的 `account is banned`(`banReason` 为空时用)也要换成中文, - * 否则这个 key 等于白加。 - */ - private fun banMessage(result: ApiResult<*>): String { - val reason = result.message - if (!result.serverMessage || reason.isBlank() || reason == BACKEND_DEFAULT_BAN_REASON) { - return Language.get("signin.failed.banned") + private fun localizeLoginError(result: ApiResult<*>): String { + result.serverText()?.let { return it } + return when { + result.statusCode == 0 -> Language.get("signin.failed.network") + result.statusCode == 401 -> Language.get("signin.failed.credentials") + // 并进 401 的话玩家看到的是「账号或密码不正确」,会反复改密码而不是来申诉。 + result.statusCode == 403 -> Language.get("signin.failed.banned") + result.statusCode == 429 -> Language.get("signin.failed.throttled") + result.statusCode >= 500 -> Language.get("signin.failed.server") + else -> Language.get("signin.failed") } - return reason } override fun signOut() { @@ -212,16 +196,10 @@ class NativeSignInScreen( back() } - override fun canOpenWebsite(): Boolean = Desktop.isDesktopSupported() + override fun canOpenWebsite(): Boolean = true override fun openWebsite() { - runCatching { - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().browse(URI(WEBSITE_URL)) - } - }.onFailure { exception -> - logger.warn("Failed to open the FPSMaster website", exception) - } + NativeDialogs.openUrl(WEBSITE_URL) } } } diff --git a/src/main/kotlin/top/fpsmaster/ui/kit/NativeDialogs.kt b/src/main/kotlin/top/fpsmaster/ui/kit/NativeDialogs.kt new file mode 100644 index 0000000..3a6cc2b --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/ui/kit/NativeDialogs.kt @@ -0,0 +1,109 @@ +package top.fpsmaster.ui.kit + +//? if >=1.21.11 { +import net.minecraft.util.Util +//?} else { +/*import net.minecraft.Util +*///?} +import org.lwjgl.system.MemoryStack +import org.lwjgl.util.tinyfd.TinyFileDialogs +import top.fpsmaster.logger +import top.fpsmaster.mc +import java.io.File +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Links, folders and file pickers without AWT. Minecraft's Main sets `java.awt.headless=true`, so + * `Desktop.browse` is never supported and constructing a `FileDialog` throws `HeadlessException`. + * + * Links and folders go through Minecraft's own launcher (`open` / `xdg-open` / `rundll32`). File + * pickers use LWJGL's tinyfd, which every supported game version ships (lwjgl-tinyfd 3.3.1–3.4.1). + */ +object NativeDialogs { + /** tinyfd keeps its result in static buffers, so only one dialog may be open at a time. */ + private val dialogOpen = AtomicBoolean(false) + + /** Opens [url] in the system browser. Returns false when the URL could not be handed off. */ + fun openUrl(url: String): Boolean { + if (url.isBlank()) { + return false + } + return try { + Util.getPlatform().openUri(URI(url)) + true + } catch (exception: Exception) { + logger.warn("Failed to open {}", url, exception) + false + } + } + + /** Opens [directory] in the system file manager. */ + fun openFolder(directory: Path): Boolean { + return try { + Util.getPlatform().openFile(directory.toFile()) + true + } catch (exception: Exception) { + logger.warn("Failed to open {}", directory, exception) + false + } + } + + /** + * Shows a native open-file dialog on a worker thread (tinyfd has no main-thread requirement on + * any platform, and running it off the render thread keeps the game window responsive). + * [onResult] runs on the render thread with the chosen files, or an empty list when the user + * cancelled or the dialog could not be shown. Returns false if another dialog is already open. + * + * [patterns] are glob filters such as `*.png`. Titles must not contain quotes: tinyfd rejects them. + */ + fun pickFiles( + title: String, + patterns: List, + description: String, + multiple: Boolean, + onResult: (List) -> Unit + ): Boolean { + if (!dialogOpen.compareAndSet(false, true)) { + return false + } + val worker = Thread({ + val files = try { + parse(openDialog(title.replace("'", "").replace("\"", ""), patterns, description, multiple)) + } catch (throwable: Throwable) { + // UnsatisfiedLinkError when the tinyfd native is missing, or a platform failure. + logger.warn("Native file dialog failed", throwable) + emptyList() + } finally { + dialogOpen.set(false) + } + mc.execute { onResult(files) } + }, "FPSMaster-FileDialog") + worker.isDaemon = true + worker.start() + return true + } + + private fun openDialog(title: String, patterns: List, description: String, multiple: Boolean): String? { + MemoryStack.stackPush().use { stack -> + val filters = if (patterns.isEmpty()) { + null + } else { + stack.mallocPointer(patterns.size).also { buffer -> + patterns.forEach { buffer.put(stack.UTF8(it)) } + buffer.flip() + } + } + return TinyFileDialogs.tinyfd_openFileDialog(title, null, filters, description.ifBlank { null }, multiple) + } + } + + /** tinyfd joins multiple selections with '|'. */ + internal fun parse(raw: String?): List { + if (raw.isNullOrBlank()) { + return emptyList() + } + return raw.split('|').filter { it.isNotBlank() }.map(::File) + } +} diff --git a/src/main/kotlin/top/fpsmaster/ui/kit/ToolkitScreen.kt b/src/main/kotlin/top/fpsmaster/ui/kit/ToolkitScreen.kt index 57377bf..d1fc5e4 100644 --- a/src/main/kotlin/top/fpsmaster/ui/kit/ToolkitScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/kit/ToolkitScreen.kt @@ -17,14 +17,25 @@ import net.minecraft.client.input.MouseButtonEvent //?} import net.minecraft.network.chat.Component import org.lwjgl.glfw.GLFW +import top.fpsmaster.mc +import top.fpsmaster.screenCompat import top.fpsmaster.module.impl.auxiliary.ClientSettings +import top.fpsmaster.notification.NotificationManager +import top.fpsmaster.windowHandleCompat import top.fpsmaster.prism.input.FrameInput import top.fpsmaster.prism.input.Keys import top.fpsmaster.prism.theme.Theme import top.fpsmaster.prism.widget.UiFrame abstract class ToolkitScreen(title: Component) : Screen(title) { - protected val frameInput = FrameInput() + protected val frameInput = FrameInput().apply { + // Without a backend FrameInput keeps a private in-memory clipboard, so copy/paste would never + // leave the current screen. + setClipboardBackend({ mc.keyboardHandler.clipboard }, { mc.keyboardHandler.clipboard = it }) + } + + /** Fractional trackpad scroll not yet committed as a whole wheel step. */ + private var wheelRemainder = 0.0 protected abstract fun renderUi(ui: UiFrame) @@ -66,12 +77,17 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { //?} try { frameInput.setMouse(mouseX, mouseY) + frameInput.setKeyDown(Keys.SHIFT, shiftDown()) val canvas = NovaCanvas(guiGraphics, font) val host = NovaHost(canvas, frameInput, font, width.toFloat(), height.toFloat()) // 立即模式:按钮回调在这里面跑。切屏之类不能在 render 栈里做的事由 // DeferredUi 排到下一次 runTick 头部。 DeferredUi.whilePainting { - renderUi(UiFrame(host, theme())) + val ui = UiFrame(host, theme()) + renderUi(ui) + // HUD notifications are not drawn under Prism screens (see MixinGui), so the screen + // paints them on top itself — e.g. "config restored from backup" on the title screen. + NotificationManager.paint(ui) } frameInput.endFrame() } finally { @@ -88,6 +104,14 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { // 里没人消费。等这个实例被当作 `parent` 再显示出来时,第一帧会拿旧坐标把它重放一次 // ——对普通格子只是「菜单又开了一下」,对 Continue 格子就是直接发起一次连接。离屏时清干净。 frameInput.endFrame() + // The panel-blur target is a full-framebuffer render target (~33 MB at 4K). Once no Prism + // screen is open it has no use; drop it. Deferred to the next runTick because the screen + // replacing this one is not set yet while removed() runs. + DeferredUi.defer(Runnable { + if (mc.screenCompat !is ToolkitScreen) { + NovaBlur.release() + } + }) } //? if >=1.21.11 { @@ -102,6 +126,11 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { } override fun keyPressed(event: KeyEvent): Boolean { + val shortcut = editShortcut(event.key(), event.modifiers()) + if (shortcut != 0) { + frameInput.pressKey(shortcut) + return true + } mapKey(event.key()).let { frameInput.pressKey(it) } frameInput.pressRawKey(event.key()) if (event.key() == GLFW.GLFW_KEY_ESCAPE && shouldCloseOnEsc()) { @@ -129,6 +158,11 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { } override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + val shortcut = editShortcut(keyCode, modifiers) + if (shortcut != 0) { + frameInput.pressKey(shortcut) + return true + } mapKey(keyCode).let { frameInput.pressKey(it) } frameInput.pressRawKey(keyCode) if (keyCode == GLFW.GLFW_KEY_ESCAPE && shouldCloseOnEsc()) { @@ -147,12 +181,12 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { //? if >=1.20.5 { override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollX: Double, scrollY: Double): Boolean { - frameInput.addWheel(if (scrollY > 0) 1 else -1) + addScroll(scrollY) return true } //?} else { /*override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollY: Double): Boolean { - frameInput.addWheel(if (scrollY > 0) 1 else -1) + addScroll(scrollY) return true }*/ //?} @@ -165,7 +199,49 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { return true } + /** + * Trackpads report fractional deltas (0.1..0.5 per event) and pure horizontal swipes as + * scrollY == 0; Prism counts whole wheel steps, so accumulate and commit only full steps. + */ + private fun addScroll(scrollY: Double) { + if (scrollY == 0.0) { + return + } + if (wheelRemainder != 0.0 && (wheelRemainder > 0) != (scrollY > 0)) { + wheelRemainder = 0.0 + } + wheelRemainder += scrollY + val steps = wheelRemainder.toInt() + if (steps != 0) { + frameInput.addWheel(steps) + wheelRemainder -= steps + } + } + + private fun shiftDown(): Boolean { + val window = windowHandleCompat() + return GLFW.glfwGetKey(window, GLFW.GLFW_KEY_LEFT_SHIFT) == GLFW.GLFW_PRESS || + GLFW.glfwGetKey(window, GLFW.GLFW_KEY_RIGHT_SHIFT) == GLFW.GLFW_PRESS + } + + /** Ctrl+A/C/X/V (Cmd on macOS) as Prism editing keys instead of letter input; 0 = not a shortcut. */ + private fun editShortcut(glfw: Int, modifiers: Int): Int { + val primary = if (ON_MAC) GLFW.GLFW_MOD_SUPER else GLFW.GLFW_MOD_CONTROL + if (modifiers and primary == 0) { + return 0 + } + return when (glfw) { + GLFW.GLFW_KEY_A -> Keys.SELECT_ALL + GLFW.GLFW_KEY_C -> Keys.COPY + GLFW.GLFW_KEY_X -> Keys.CUT + GLFW.GLFW_KEY_V -> Keys.PASTE + else -> 0 + } + } + private fun mapKey(glfw: Int): Int = when (glfw) { + GLFW.GLFW_KEY_HOME -> Keys.HOME + GLFW.GLFW_KEY_END -> Keys.END GLFW.GLFW_KEY_BACKSPACE -> Keys.BACKSPACE GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> Keys.ENTER GLFW.GLFW_KEY_ESCAPE -> Keys.ESCAPE @@ -174,4 +250,8 @@ abstract class ToolkitScreen(title: Component) : Screen(title) { GLFW.GLFW_KEY_DELETE -> Keys.DELETE else -> glfw } + + private companion object { + val ON_MAC: Boolean = System.getProperty("os.name", "").lowercase().startsWith("mac") + } } diff --git a/src/main/resources/assets/fpsmaster/lang/en_us.lang b/src/main/resources/assets/fpsmaster/lang/en_us.lang index 3f507df..120aee3 100644 --- a/src/main/resources/assets/fpsmaster/lang/en_us.lang +++ b/src/main/resources/assets/fpsmaster/lang/en_us.lang @@ -1118,3 +1118,102 @@ replay.status.closed=Replay closed replay.hud.hint=ESC for controls · Q/E roll camera (hold Shift for fine) · R level replay.pose=Roll %s° FOV %s° replay.hud.paused=Paused +mainmenu.account.ms.copylink=Copy sign-in link +mainmenu.account.ms.linkcopied=Sign-in link copied. Paste it into your browser. +mainmenu.account.ms.openfailed=Could not open a browser. Copy the sign-in link and open it manually. +mainmenu.account.remove.confirm=Click again to remove +music.local.picker=Choose local music +music.local.picker.filter=Audio files +music.local.artist=Local music +backgroundselector.filedialog.filter=Image files +backgroundselector.import.failed=Could not import the selected image. +configprofiles.status.picking=Choose a config file in the dialog +configprofiles.status.picker_busy=A file dialog is already open +music.settings.title=Music settings +music.settings.subtitle=Playback and lyrics +music.settings.lyricsize=Lyric size +music.settings.lyriclines=Lines shown +music.settings.lines.unit=%d lines +music.settings.hud=Lyrics overlay +music.settings.translation=Show translation +music.settings.smooth=Smooth scrolling +music.settings.background=Lyric background +music.settings.mode=Playback mode +music.mode.sequential=In order +music.mode.shuffle=Shuffle +music.mode.repeatone=Repeat one +music.source.netease=NetEase +music.source.qq=QQ Music +music.tab.discover=Discover +music.tab.playlists=Playlists +music.tab.search=Search +music.tab.local=Local +music.search.placeholder=Search songs or artists… +music.search.hint=Type keywords and press Enter +music.list.loading=Loading… +music.list.empty=No songs +music.list.needlogin=Sign in to view +music.list.error=Failed to load +music.list.retry=Retry +music.playlists.empty=No playlists +music.local.import=Import music +music.local.title=Local music +music.local.empty=Import MP3, WAV or AIFF files +music.login=Sign in +music.login.title=%s sign-in +music.login.refreshqr=Refresh QR code +music.login.cookie=Cookie sign-in +music.logout=Sign out +music.logout.confirm=Sign out of %s? +music.cancel=Cancel +music.greeting.night=Late night +music.greeting.morning=Good morning +music.greeting.afternoon=Good afternoon +music.greeting.evening=Good evening +music.greeting.qq=QQ Music listener +music.greeting.netease=NetEase listener +music.greeting.sep=, +music.sub.qq=Top chart updated +music.sub.daily=Daily picks updated +music.sub.login=Sign in for daily picks +music.lyrics.loading=Loading lyrics… +music.lyrics.idle=Not playing +music.status.searchtitle=Search: %s +music.status.daily=Daily picks +music.status.hot=Hot songs +music.status.myplaylists=My playlists +music.status.fetching=Fetching link… +music.status.unplayable=Cannot play this song +music.status.trial=Preview only +music.error.busy=Too many requests, try again shortly +music.error.unknown=Failed to load +music.error.network=Network error +music.error.timeout=Request timed out +music.error.not_logged_in=Sign-in expired, please sign in again +music.error.risk_control=The platform asked for verification, try again later +music.error.bad_response=The platform returned an unexpected response +music.error.api_error=The platform returned an error +music.error.qqplaylists=QQ Music playlists are not supported yet +cosmetics.purchase.network=Could not reach the server. Check your connection and try again. +replay.exportfps=Export FPS +replay.followrecorder=Follow the recorder +replay.autorecord=Record on joining a server +replay.clipbuffer=Keep recent footage +replay.clipminutes=Footage to keep (minutes) +replay.clipkey=Save clip key +replay.clip.title=Replay clip +replay.clip.saving=Saving recent footage… +replay.clip.saved=Clip saved: %s +replay.clip.failed=Could not save the clip; see the log +replay.clip.busy=The previous clip is still being saved +replay.clip.empty=Nothing to save yet, try again shortly +replay.status.truncated=Recording too large; only the first %s minutes were loaded +configbackup.title=Config backup +configbackup.restored=The config file was damaged and has been restored from the %s backup +configbackup.reset=The config file was damaged and no backup loaded; defaults restored (original kept as %s) +configbackup.savefailed=Could not save the config; see the log +configbackup.none=No backup differs from the current config +configbackup.rollback=Roll back to the previous backup +configbackup.rollback.confirm=Click again to roll back +configbackup.rollback.done=Rolled back to the %s backup +configbackup.rollback.failed=Rollback failed; see the log diff --git a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang index 197c826..34e42dc 100644 --- a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang +++ b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang @@ -1121,3 +1121,102 @@ replay.status.closed=已关闭回放 replay.hud.hint=ESC 打开回放控制 · Q/E 倾斜镜头(按住 Shift 微调)· R 回正 replay.pose=倾斜 %s° 视角 %s° replay.hud.paused=已暂停 +mainmenu.account.ms.copylink=点击复制登录链接 +mainmenu.account.ms.linkcopied=已复制登录链接,请粘贴到浏览器中打开。 +mainmenu.account.ms.openfailed=无法打开浏览器,请复制登录链接后手动打开。 +mainmenu.account.remove.confirm=再点一次删除 +music.local.picker=选择本地音乐 +music.local.picker.filter=音频文件 +music.local.artist=本地音乐 +backgroundselector.filedialog.filter=图片文件 +backgroundselector.import.failed=无法导入所选图片。 +configprofiles.status.picking=请在弹出的窗口中选择配置文件 +configprofiles.status.picker_busy=已有一个文件选择窗口打开 +music.settings.title=音乐设置 +music.settings.subtitle=播放与歌词 +music.settings.lyricsize=歌词字号 +music.settings.lyriclines=显示行数 +music.settings.lines.unit=%d 行 +music.settings.hud=桌面歌词 +music.settings.translation=显示翻译 +music.settings.smooth=平滑滚动 +music.settings.background=歌词背景 +music.settings.mode=播放模式 +music.mode.sequential=顺序 +music.mode.shuffle=乱序 +music.mode.repeatone=单曲 +music.source.netease=网易云 +music.source.qq=QQ 音乐 +music.tab.discover=发现 +music.tab.playlists=歌单 +music.tab.search=搜索 +music.tab.local=本地 +music.search.placeholder=搜索歌曲、歌手… +music.search.hint=输入关键字并回车搜索 +music.list.loading=加载中… +music.list.empty=暂无歌曲 +music.list.needlogin=登录后查看 +music.list.error=加载失败 +music.list.retry=重试 +music.playlists.empty=暂无歌单 +music.local.import=导入音乐 +music.local.title=本地音乐 +music.local.empty=导入 MP3、WAV 或 AIFF 文件 +music.login=登录 +music.login.title=%s 登录 +music.login.refreshqr=刷新二维码 +music.login.cookie=Cookie 登录 +music.logout=退出登录 +music.logout.confirm=退出%s账号? +music.cancel=取消 +music.greeting.night=夜深了 +music.greeting.morning=早上好 +music.greeting.afternoon=下午好 +music.greeting.evening=晚上好 +music.greeting.qq=Q音听友 +music.greeting.netease=云村居民 +music.greeting.sep=, +music.sub.qq=热歌榜已更新 +music.sub.daily=每日推荐已更新 +music.sub.login=登录后查看每日推荐 +music.lyrics.loading=歌词加载中… +music.lyrics.idle=未在播放 +music.status.searchtitle=搜索:%s +music.status.daily=每日推荐 +music.status.hot=热歌 +music.status.myplaylists=我的歌单 +music.status.fetching=获取链接… +music.status.unplayable=无法播放 +music.status.trial=试听 +music.error.busy=请求太多,请稍后再试 +music.error.unknown=加载失败 +music.error.network=网络连接失败 +music.error.timeout=请求超时 +music.error.not_logged_in=登录已失效,请重新登录 +music.error.risk_control=平台要求安全验证,请稍后再试 +music.error.bad_response=平台返回了无法识别的数据 +music.error.api_error=平台返回错误 +music.error.qqplaylists=QQ 音乐歌单暂未接入 +cosmetics.purchase.network=无法连接服务器,请检查网络后重试 +replay.exportfps=导出帧率 +replay.followrecorder=镜头跟随录制者 +replay.autorecord=进服自动录制 +replay.clipbuffer=缓存最近片段 +replay.clipminutes=缓存时长(分钟) +replay.clipkey=保存片段热键 +replay.clip.title=回放片段 +replay.clip.saving=正在保存最近的片段… +replay.clip.saved=已保存片段:%s +replay.clip.failed=片段保存失败,详见日志 +replay.clip.busy=上一个片段还在保存 +replay.clip.empty=还没有可保存的片段,请稍候 +replay.status.truncated=录像过大,只载入了前 %s 分钟 +configbackup.title=配置备份 +configbackup.restored=配置文件损坏,已从 %s 的备份恢复 +configbackup.reset=配置文件损坏且没有可用备份,已恢复默认设置(原文件保存为 %s) +configbackup.savefailed=配置保存失败,详见日志 +configbackup.none=没有与当前配置不同的备份 +configbackup.rollback=回滚到上一个备份 +configbackup.rollback.confirm=再点一次确认回滚 +configbackup.rollback.done=已回滚到 %s 的备份 +configbackup.rollback.failed=回滚失败,详见日志 diff --git a/src/main/resources/fpsmaster-1.19.2.mixins.json b/src/main/resources/fpsmaster-1.19.2.mixins.json index ddf6c33..57d86a3 100644 --- a/src/main/resources/fpsmaster-1.19.2.mixins.json +++ b/src/main/resources/fpsmaster-1.19.2.mixins.json @@ -54,6 +54,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/main/resources/fpsmaster-1.20.1.mixins.json b/src/main/resources/fpsmaster-1.20.1.mixins.json index 6c87b83..00d6f9d 100644 --- a/src/main/resources/fpsmaster-1.20.1.mixins.json +++ b/src/main/resources/fpsmaster-1.20.1.mixins.json @@ -55,6 +55,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/main/resources/fpsmaster-1.21.1.mixins.json b/src/main/resources/fpsmaster-1.21.1.mixins.json index 0d900c4..03a8139 100644 --- a/src/main/resources/fpsmaster-1.21.1.mixins.json +++ b/src/main/resources/fpsmaster-1.21.1.mixins.json @@ -55,6 +55,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/main/resources/fpsmaster-1.21.8.mixins.json b/src/main/resources/fpsmaster-1.21.8.mixins.json index e35739e..3c3a5bc 100644 --- a/src/main/resources/fpsmaster-1.21.8.mixins.json +++ b/src/main/resources/fpsmaster-1.21.8.mixins.json @@ -60,6 +60,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/main/resources/fpsmaster-26.2.mixins.json b/src/main/resources/fpsmaster-26.2.mixins.json index f4a84fc..93ce6b9 100644 --- a/src/main/resources/fpsmaster-26.2.mixins.json +++ b/src/main/resources/fpsmaster-26.2.mixins.json @@ -65,6 +65,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/main/resources/fpsmaster.mixins.json b/src/main/resources/fpsmaster.mixins.json index e827e1b..0bf186b 100644 --- a/src/main/resources/fpsmaster.mixins.json +++ b/src/main/resources/fpsmaster.mixins.json @@ -60,6 +60,7 @@ "MixinScreen", "MixinScreenEffectRenderer", "MixinServerSelectionList", + "MixinOnlineServerEntry", "MixinServerSelectionListEntry", "MixinSoundEngine", "MixinTitleScreen", diff --git a/src/test/kotlin/top/fpsmaster/auth/AuthServiceLaunchTokenTest.kt b/src/test/kotlin/top/fpsmaster/auth/AuthServiceLaunchTokenTest.kt new file mode 100644 index 0000000..4c89849 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/auth/AuthServiceLaunchTokenTest.kt @@ -0,0 +1,41 @@ +package top.fpsmaster.auth + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AuthServiceLaunchTokenTest { + private val keys = listOf("fpsmaster.auth.token", "fpsmaster.auth.refreshToken", "fpsmaster.auth.tokenExpiresAt") + + @AfterTest + fun cleanUp() { + keys.forEach { System.clearProperty(it) } + } + + @Test + fun `environment variable wins and the properties are cleared`() { + System.setProperty("fpsmaster.auth.token", "from-property") + System.setProperty("fpsmaster.auth.refreshToken", "refresh") + + val launch = AuthService.takeLauncherCredentials("from-env") + + assertEquals("from-env", launch?.token) + assertEquals("refresh", launch?.refreshToken) + keys.forEach { assertNull(System.getProperty(it), "$it must not stay readable") } + } + + @Test + fun `falls back to the legacy property`() { + System.setProperty("fpsmaster.auth.token", "from-property") + + assertEquals("from-property", AuthService.takeLauncherCredentials(null)?.token) + assertNull(System.getProperty("fpsmaster.auth.token")) + } + + @Test + fun `blank values mean no launcher token`() { + System.setProperty("fpsmaster.auth.token", " ") + assertNull(AuthService.takeLauncherCredentials("")) + } +} diff --git a/src/test/kotlin/top/fpsmaster/config/ConfigBackupsTest.kt b/src/test/kotlin/top/fpsmaster/config/ConfigBackupsTest.kt new file mode 100644 index 0000000..b5055c3 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/config/ConfigBackupsTest.kt @@ -0,0 +1,56 @@ +package top.fpsmaster.config + +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ConfigBackupsTest { + private var now = 1_000_000L + private val root = Files.createTempDirectory("nova-backups") + private val backups = ConfigBackups(root, keep = 3, minIntervalMs = 60_000L, clock = { now }) + + @Test + fun `unchanged content and saves inside the interval take no copy`() { + assertNotNull(backups.snapshot("default", "a")) + now += 1_000 + assertNull(backups.snapshot("default", "b"), "inside the interval") + now += 60_000 + assertNotNull(backups.snapshot("default", "b")) + now += 60_000 + assertNull(backups.snapshot("default", "b"), "same as the newest copy") + assertEquals(2, backups.list("default").size) + } + + @Test + fun `forced snapshots ignore the interval and old copies are pruned`() { + repeat(5) { index -> + now += 1_000 + backups.snapshot("default", "v$index", force = true) + } + val kept = backups.list("default") + assertEquals(3, kept.size) + assertEquals("v4", kept.first().readText(), "newest first") + } + + @Test + fun `rollback target is the newest copy that differs from the current file`() { + backups.snapshot("default", "old", force = true) + now += 1_000 + backups.snapshot("default", "current", force = true) + + assertEquals("old", backups.latestDifferentFrom("default", "current")?.readText()) + assertNull(backups.latestDifferentFrom("missing", "x")) + } + + @Test + fun `atomic write replaces the whole file`() { + val target = root.resolve("file.json") + AtomicFiles.write(target, "first") + AtomicFiles.write(target, "second", ownerOnly = true) + assertEquals("second", target.readText()) + assertEquals(listOf("file.json"), Files.list(root).use { files -> files.map { it.fileName.toString() }.toList() }) + } +} diff --git a/src/test/kotlin/top/fpsmaster/replay/ClipRingTest.kt b/src/test/kotlin/top/fpsmaster/replay/ClipRingTest.kt new file mode 100644 index 0000000..11345dc --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/replay/ClipRingTest.kt @@ -0,0 +1,74 @@ +package top.fpsmaster.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ClipRingTest { + + private fun packet(millis: Int, size: Int = 1) = ReplayRecord.packet(millis, ByteArray(size)) + + @Test + fun `a clip needs a complete keyframe`() { + val ring = ClipRing(1L shl 30) + val segment = ring.beginSegment(0, listOf(byteArrayOf(1))) + ring.add(packet(10)) + assertNull(ring.clip(), "terrain snapshot still in progress") + + ring.finishKeyframe(segment) + val clip = assertNotNull(ring.clip()) + assertEquals(1, clip.keyframe.size) + assertEquals(1, clip.records.size) + } + + @Test + fun `old segments leave the window only once the next keyframe is complete`() { + val ring = ClipRing(1L shl 30) + ring.finishKeyframe(ring.beginSegment(0, emptyList())) + ring.add(packet(100)) + val second = ring.beginSegment(60_000, emptyList()) + ring.add(packet(60_100)) + + ring.trim(nowMillis = 200_000, windowMillis = 60_000) + assertEquals(2, ring.segmentCount, "second keyframe incomplete: the first is still the only start") + + ring.finishKeyframe(second) + ring.trim(nowMillis = 200_000, windowMillis = 60_000) + assertEquals(1, ring.segmentCount) + val clip = assertNotNull(ring.clip()) + assertEquals(60_000, clip.baseMillis) + assertEquals(listOf(100), clip.records.map { ClipRing.rebase(it, clip.baseMillis).millis }) + } + + @Test + fun `segments still inside the window are kept`() { + val ring = ClipRing(1L shl 30) + ring.finishKeyframe(ring.beginSegment(0, emptyList())) + ring.finishKeyframe(ring.beginSegment(60_000, emptyList())) + ring.trim(nowMillis = 90_000, windowMillis = 120_000) + assertEquals(2, ring.segmentCount) + assertEquals(0, assertNotNull(ring.clip()).baseMillis, "the clip covers the whole window") + } + + @Test + fun `the byte budget evicts oldest segments even inside the window`() { + val budget = 10_000L + val ring = ClipRing(budget) + ring.finishKeyframe(ring.beginSegment(0, emptyList())) + repeat(10) { ring.add(packet(it, 1000)) } + ring.finishKeyframe(ring.beginSegment(1_000, emptyList())) + ring.add(packet(1_001, 10)) + + ring.trim(nowMillis = 2_000, windowMillis = 600_000) + assertEquals(1, ring.segmentCount) + assertTrue(ring.totalBytes <= budget) + } + + @Test + fun `rebase never produces negative timestamps`() { + assertEquals(0, ClipRing.rebase(packet(5), 10).millis) + assertEquals(15, ClipRing.rebase(packet(25), 10).millis) + } +} diff --git a/src/test/kotlin/top/fpsmaster/replay/ReplayWriteQueueTest.kt b/src/test/kotlin/top/fpsmaster/replay/ReplayWriteQueueTest.kt index 99864e8..eb3e8d8 100644 --- a/src/test/kotlin/top/fpsmaster/replay/ReplayWriteQueueTest.kt +++ b/src/test/kotlin/top/fpsmaster/replay/ReplayWriteQueueTest.kt @@ -66,6 +66,23 @@ class ReplayWriteQueueTest { assertEquals(1000, written) } + @Test + fun `finish after finishAsync still waits for the draining writer`() { + val (file, writer) = writer() + val queue = ReplayWriteQueue(writer) + queue.startWriter("nova-replay-test-writer") + + repeat(1000) { queue.offer(ReplayRecord.packet(it, byteArrayOf(it.toByte()))) } + queue.finishAsync() + queue.finish() + + assertFalse(queue.isWriterAlive) + val written = NovaReplayFile.openForRead(file, "1.21.11").use { reader -> + generateSequence { reader.read() }.count() + } + assertEquals(1000, written) + } + @Test fun `flushing follows the clock rather than the record count`() { val (file, writer) = writer()