From 629e0f095e8b8b8e7b18d0228c37991912070b00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 04:06:15 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(replay):=20=E8=BF=9B=E5=85=A5=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E5=89=8D=E5=85=88=E9=80=80=E5=87=BA=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E4=B8=96=E7=95=8C=E6=88=96=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在已连接世界或服务器时打开回放会留下原通道继续发包。 playback.open 先断开现世/服务器,再创建 isolated listener。 Co-authored-by: Gao Yu --- .../top/fpsmaster/replay/ReplayPlayback.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index 8368cbf..a530097 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -6,6 +6,7 @@ import top.fpsmaster.mc import top.fpsmaster.replay.adapter.DirectorRenderAdapter import top.fpsmaster.replay.adapter.ReplayPacketAdapter import top.fpsmaster.replay.adapter.ReplayWorldAdapter +import top.fpsmaster.screenCompat import java.io.File /** @@ -59,6 +60,7 @@ class ReplayPlayback private constructor( if (session != null) { return } + leaveCurrentSession() val profile = GameProfile(header.profile.recorderId, header.profile.recorderName) session = ReplayWorldAdapter.open(profile) cursor = 0 @@ -67,6 +69,27 @@ class ReplayPlayback private constructor( applyUntil(0) } + /** + * Drops the live singleplayer or multiplayer session before the isolated replay listener + * takes over. Opening playback on top of a live connection leaves that channel sending + * movement, which looks like flight on the real server. + */ + private fun leaveCurrentSession() { + if (mc.level == null && mc.player == null && mc.connection == null) { + return + } + logger.info("[replay] leaving current world/server before playback") + try { + //? if >=1.20.5 { + mc.disconnect(mc.screenCompat, false) + //?} else { + /*mc.clearLevel(mc.screenCompat) + *///?} + } catch (failure: Exception) { + logger.warn("[replay] failed to leave current world/server: ${failure.message}") + } + } + fun play() { isPaused = false lastFrameNanos = System.nanoTime() From b1fdd09798e683eebe0113050bd7f94eba07fc12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 04:11:46 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(replay):=20disconnect=20=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E9=9D=9E=E7=A9=BA=20Screen=EF=BC=8C=E5=B9=B6=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20isolated=20=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.21.11 / 26.2 的 Minecraft.disconnect 参数是 Screen 而非 Screen?。 回放 seek 重建时当前连接已是 EmbeddedChannel,不能再当现世断开。 Co-authored-by: Gao Yu --- .../top/fpsmaster/replay/ReplayPlayback.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index a530097..f39fa10 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -1,6 +1,8 @@ package top.fpsmaster.replay import com.mojang.authlib.GameProfile +import io.netty.channel.embedded.EmbeddedChannel +import net.minecraft.client.gui.screens.TitleScreen import top.fpsmaster.logger import top.fpsmaster.mc import top.fpsmaster.replay.adapter.DirectorRenderAdapter @@ -73,23 +75,43 @@ class ReplayPlayback private constructor( * Drops the live singleplayer or multiplayer session before the isolated replay listener * takes over. Opening playback on top of a live connection leaves that channel sending * movement, which looks like flight on the real server. + * + * The isolated listener uses an [EmbeddedChannel]; that is not a live server and must not + * be torn down here, or backwards seek ([rebuild]) would flash a menu. */ private fun leaveCurrentSession() { - if (mc.level == null && mc.player == null && mc.connection == null) { + if (!hasLiveSession()) { return } logger.info("[replay] leaving current world/server before playback") try { + val screen = mc.screenCompat ?: TitleScreen() //? if >=1.20.5 { - mc.disconnect(mc.screenCompat, false) + mc.disconnect(screen, false) //?} else { - /*mc.clearLevel(mc.screenCompat) + /*mc.clearLevel(screen) *///?} } catch (failure: Exception) { logger.warn("[replay] failed to leave current world/server: ${failure.message}") } } + private fun hasLiveSession(): Boolean { + if (mc.level == null && mc.player == null && mc.connection == null) { + return false + } + return !isIsolatedReplayConnection() + } + + private fun isIsolatedReplayConnection(): Boolean { + val connection = mc.connection?.connection ?: return false + return try { + connection.channel() is EmbeddedChannel + } catch (unavailable: Throwable) { + false + } + } + fun play() { isPaused = false lastFrameNanos = System.nanoTime() From caf79ec10c34e4233864bb65e8d9ff6e920a248e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 04:14:56 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(replay):=20Connection.channel=20?= =?UTF-8?q?=E6=98=AF=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=B8=8D=E8=A6=81=E5=BD=93?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各版本 Yarn 里 channel 都是 Channel 属性,channel() 会被当成对 Channel 做 invoke。 Co-authored-by: Gao Yu --- src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index f39fa10..fc82cb8 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -106,7 +106,7 @@ class ReplayPlayback private constructor( private fun isIsolatedReplayConnection(): Boolean { val connection = mc.connection?.connection ?: return false return try { - connection.channel() is EmbeddedChannel + connection.channel is EmbeddedChannel } catch (unavailable: Throwable) { false } From 202a89670b1fc892603a66ea3f2e7652c5a64cb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 04:18:18 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(replay):=20=E5=8E=BB=E6=8E=89=E5=AF=B9?= =?UTF-8?q?=20Connection.channel=20=E7=9A=84=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel 在各版本都是私有字段。现世只在第一次 open 时断开, seek 重建只重建 isolated session,不再走 disconnect。 Co-authored-by: Gao Yu --- .../top/fpsmaster/replay/ReplayPlayback.kt | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index fc82cb8..a78377c 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -1,7 +1,6 @@ package top.fpsmaster.replay import com.mojang.authlib.GameProfile -import io.netty.channel.embedded.EmbeddedChannel import net.minecraft.client.gui.screens.TitleScreen import top.fpsmaster.logger import top.fpsmaster.mc @@ -63,12 +62,7 @@ class ReplayPlayback private constructor( return } leaveCurrentSession() - val profile = GameProfile(header.profile.recorderId, header.profile.recorderName) - session = ReplayWorldAdapter.open(profile) - cursor = 0 - positionMillis = 0 - lastFrameNanos = System.nanoTime() - applyUntil(0) + startIsolatedSession() } /** @@ -76,11 +70,11 @@ class ReplayPlayback private constructor( * takes over. Opening playback on top of a live connection leaves that channel sending * movement, which looks like flight on the real server. * - * The isolated listener uses an [EmbeddedChannel]; that is not a live server and must not - * be torn down here, or backwards seek ([rebuild]) would flash a menu. + * Seek rebuilds go through [startIsolatedSession] only. The isolated listener is not a + * live server and must not be disconnected with a menu in between. */ private fun leaveCurrentSession() { - if (!hasLiveSession()) { + if (mc.level == null && mc.player == null && mc.connection == null) { return } logger.info("[replay] leaving current world/server before playback") @@ -96,20 +90,13 @@ class ReplayPlayback private constructor( } } - private fun hasLiveSession(): Boolean { - if (mc.level == null && mc.player == null && mc.connection == null) { - return false - } - return !isIsolatedReplayConnection() - } - - private fun isIsolatedReplayConnection(): Boolean { - val connection = mc.connection?.connection ?: return false - return try { - connection.channel is EmbeddedChannel - } catch (unavailable: Throwable) { - false - } + private fun startIsolatedSession() { + val profile = GameProfile(header.profile.recorderId, header.profile.recorderName) + session = ReplayWorldAdapter.open(profile) + cursor = 0 + positionMillis = 0 + lastFrameNanos = System.nanoTime() + applyUntil(0) } fun play() { @@ -167,7 +154,7 @@ class ReplayPlayback private constructor( val active = session ?: return ReplayWorldAdapter.close(active) session = null - open() + startIsolatedSession() } private fun applyUntil(target: Int) {