diff --git a/mirai-core-utils/src/commonMain/kotlin/SizedCache.kt b/mirai-core-utils/src/commonMain/kotlin/SizedCache.kt
new file mode 100644
index 000000000..c093934b4
--- /dev/null
+++ b/mirai-core-utils/src/commonMain/kotlin/SizedCache.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.utils
+
+import kotlinx.atomicfu.locks.withLock
+import java.util.concurrent.locks.ReentrantLock
+
+@Suppress("unused", "UNCHECKED_CAST")
+public class SizedCache<T>(size: Int) : Iterable<T> {
+    public val lock: ReentrantLock = ReentrantLock()
+    public val data: Array<Any?> = arrayOfNulls(size)
+
+    public var filled: Boolean = false
+    public var idx: Int = 0
+
+    public fun emit(v: T) {
+        lock.withLock {
+            data[idx] = v
+            idx++
+            if (idx == data.size) {
+                filled = true
+                idx = 0
+            }
+        }
+    }
+
+    override fun iterator(): Iterator<T> {
+        if (filled) {
+            return object : Iterator<T> {
+                private var idx0: Int = idx
+                private var floopend = false
+                override fun hasNext(): Boolean = !floopend || idx0 != idx
+                override fun next(): T {
+                    val rsp = data[idx0] as T
+                    idx0++
+                    if (idx0 == data.size) {
+                        idx0 = 0
+                        floopend = true
+                    }
+                    return rsp
+                }
+            }
+        }
+
+        return object : Iterator<T> {
+            private var idx0: Int = 0
+            override fun hasNext(): Boolean = idx0 < idx
+            override fun next(): T = data[idx0].also { idx0++ } as T
+        }
+    }
+}
\ No newline at end of file
diff --git a/mirai-core-utils/src/commonTest/kotlin/net/mamoe/mirai/utils/SizedCacheTest.kt b/mirai-core-utils/src/commonTest/kotlin/net/mamoe/mirai/utils/SizedCacheTest.kt
new file mode 100644
index 000000000..eb3b368d6
--- /dev/null
+++ b/mirai-core-utils/src/commonTest/kotlin/net/mamoe/mirai/utils/SizedCacheTest.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.utils
+
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+internal class SizedCacheTest {
+    @Test
+    fun validIfCacheNotFilled() {
+        val cache = SizedCache<String>(20)
+        val list = mutableListOf<String>()
+        repeat(10) {
+            cache.emit("-$it")
+            list.add("-$it")
+        }
+        assertEquals(list, cache.toList())
+    }
+
+    @Test
+    fun validIfNotUsed() {
+        val cache = SizedCache<String>(20)
+        assertEquals(emptyList(), cache.toList())
+    }
+
+    @Test
+    fun validIfFilled() {
+        val cache = SizedCache<String>(20)
+        val list = mutableListOf<String>()
+        repeat(20) {
+            cache.emit("-$it")
+            list.add("-$it")
+        }
+        assertEquals(list, cache.toList())
+    }
+
+    @Test
+    fun chaosTest() {
+        repeat(1000) {
+            val size = (5..200).random()
+            val list = mutableListOf<Int>()
+            val cache = SizedCache<Int>(size)
+            repeat((100..5000).random().coerceAtLeast(size)) {
+                cache.emit(it)
+                list.add(it)
+            }
+            assertEquals(
+                list.subList(list.size - size, list.size).toMutableList(),
+                cache.toMutableList(),
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/mirai-core/src/jvmTest/kotlin/netinternalkit/Ansi.kt b/mirai-core/src/jvmTest/kotlin/netinternalkit/Ansi.kt
new file mode 100644
index 000000000..47daa5cdb
--- /dev/null
+++ b/mirai-core/src/jvmTest/kotlin/netinternalkit/Ansi.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.netinternalkit
+
+// Copied from mirai-console/backend/mirai-console/src/util/AnsiMessageBuilder.kt
+
+
+@Suppress("RegExpRedundantEscape")
+private val DROP_CSI_PATTERN = """\u001b\[([\u0030-\u003F])*?([\u0020-\u002F])*?[\u0040-\u007E]""".toRegex()
+private val DROP_ANSI_PATTERN = """\u001b[\u0040–\u005F]""".toRegex()
+
+internal fun String.dropAnsi(): String = this
+    .replace(DROP_CSI_PATTERN, "") // 先进行 CSI 剔除后进行 ANSI 剔除
+    .replace(DROP_ANSI_PATTERN, "")
diff --git a/mirai-core/src/jvmTest/kotlin/netinternalkit/LogCapture.kt b/mirai-core/src/jvmTest/kotlin/netinternalkit/LogCapture.kt
new file mode 100644
index 000000000..dcea33a54
--- /dev/null
+++ b/mirai-core/src/jvmTest/kotlin/netinternalkit/LogCapture.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.internal.netinternalkit
+
+import kotlinx.atomicfu.locks.withLock
+import net.mamoe.mirai.utils.DefaultFactoryOverrides
+import net.mamoe.mirai.utils.PlatformLogger
+import net.mamoe.mirai.utils.SizedCache
+import net.mamoe.mirai.utils.currentTimeMillis
+import java.io.File
+
+internal object LogCapture {
+    lateinit var logCache: SizedCache<String>
+    private val output: (String) -> Unit = { log ->
+        println(log)
+        logCache.emit(log.dropAnsi())
+    }
+    var outputDir = File("test/capture")
+
+    fun setupCapture(maxLine: Int = 200) {
+        logCache = SizedCache(maxLine)
+        @Suppress("INVISIBLE_MEMBER")
+        DefaultFactoryOverrides.override { requester, identity ->
+            PlatformLogger(
+                identity ?: requester.kotlin.simpleName ?: requester.simpleName,
+                output
+            )
+        }
+
+        NetReplayHelperSettings.logger_console = PlatformLogger(
+            identity = "NetReplayHelper",
+            output = ::println
+        )
+        NetReplayHelperSettings.logger_file = PlatformLogger(
+            identity = "NetReplayHelper",
+            output = { log -> logCache.emit(log.dropAnsi()) }
+        )
+    }
+
+    fun saveCapture(type: String = "capture") {
+        val output = outputDir.resolve("$type-${currentTimeMillis()}.txt")
+        logCache.lock.withLock {
+            output.also { it.parentFile.mkdirs() }.bufferedWriter().use { writer ->
+                logCache.forEach { line ->
+                    writer.write(line)
+                    writer.write(10)
+                }
+            }
+        }
+    }
+}
diff --git a/mirai-core/src/jvmTest/kotlin/bootstrap/NetReplayHelper.kt b/mirai-core/src/jvmTest/kotlin/netinternalkit/NetReplayHelper.kt
similarity index 75%
rename from mirai-core/src/jvmTest/kotlin/bootstrap/NetReplayHelper.kt
rename to mirai-core/src/jvmTest/kotlin/netinternalkit/NetReplayHelper.kt
index d20814be0..455bb859b 100644
--- a/mirai-core/src/jvmTest/kotlin/bootstrap/NetReplayHelper.kt
+++ b/mirai-core/src/jvmTest/kotlin/netinternalkit/NetReplayHelper.kt
@@ -10,7 +10,7 @@
 @file:JvmName("NetReplayHelper")
 @file:Suppress("TestFunctionName")
 
-package net.mamoe.mirai.internal.bootstrap
+package net.mamoe.mirai.internal.netinternalkit
 
 import io.netty.channel.*
 import net.mamoe.mirai.Bot
@@ -38,28 +38,42 @@ import kotlin.reflect.full.declaredMembers
 import kotlin.reflect.jvm.isAccessible
 import kotlin.reflect.jvm.javaField
 
-private val droppedCommands: Collection<String> by lazy {
-    listOf(
-        "Heartbeat.Alive",
-        "wtlogin.exchange_emp",
-        "StatSvc.register",
-        "StatSvc.GetDevLoginInfo",
-        "MessageSvc.PbGetMsg",
-        "friendlist.getFriendGroupList",
-        "friendlist.GetTroopListReqV2",
-        "friendlist.GetTroopMemberListReq",
-        "ConfigPushSvc.PushReq",
-        *PacketLoggingStrategyImpl.getDefaultBlacklist().toTypedArray(),
-    )
+
+internal object NetReplayHelperSettings {
+    var commands_hide_hideAll: Collection<String> by lateinitMutableProperty {
+        listOf(
+            "Heartbeat.Alive",
+            "wtlogin.exchange_emp",
+            "StatSvc.register",
+            "StatSvc.GetDevLoginInfo",
+            "MessageSvc.PbGetMsg",
+            "friendlist.getFriendGroupList",
+            "friendlist.GetTroopListReqV2",
+            "friendlist.GetTroopMemberListReq",
+        )
+    }
+
+    var commands_hide_hideInConsole: Collection<String> by lateinitMutableProperty {
+        listOf(
+            "ConfigPushSvc.PushReq",
+            *PacketLoggingStrategyImpl.getDefaultBlacklist().toTypedArray(),
+        )
+    }
+
+    var logger_console: MiraiLogger by lateinitMutableProperty {
+        MiraiLogger.Factory.create(NetReplayHelperClass())
+    }
+
+    var logger_file: MiraiLogger = SilentLogger.withSwitch(false)
+
+    @JvmField
+    val NetReplyHelper: Class<*> = NetReplayHelperClass()
 }
 
 private fun NetReplayHelperClass(): Class<*> {
     return MethodHandles.lookup().lookupClass()
 }
 
-private val logger by lazy {
-    MiraiLogger.Factory.create(NetReplayHelperClass())
-}
 
 private fun attachNetReplayHelper(channel: Channel) {
     channel.pipeline()
@@ -71,10 +85,27 @@ private fun attachNetReplayHelper(channel: Channel) {
 private fun newRawPacketDumper(): ChannelHandler = object : ChannelInboundHandlerAdapter() {
     override fun channelRead(ctx: ChannelHandlerContext?, msg: Any?) {
         if (msg is RawIncomingPacket) {
-            if (msg.commandName in droppedCommands) {
-                logger.debug { "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=<DROPPED>" }
+            if (msg.commandName in NetReplayHelperSettings.commands_hide_hideAll) {
+                NetReplayHelperSettings.logger_console.debug {
+                    "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=<DROPPED>"
+                }
+                NetReplayHelperSettings.logger_file.debug {
+                    "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=<DROPPED>"
+                }
+                super.channelRead(ctx, msg)
+                return
+            }
+            if (msg.commandName in NetReplayHelperSettings.commands_hide_hideInConsole) {
+                NetReplayHelperSettings.logger_console.debug {
+                    "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=<DROPPED>"
+                }
             } else {
-                logger.debug { "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=${msg.body.toUHexString()}" }
+                NetReplayHelperSettings.logger_console.debug {
+                    "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=${msg.body.toUHexString()}"
+                }
+            }
+            NetReplayHelperSettings.logger_file.debug {
+                "sid=${msg.sequenceId}, cmd=${msg.commandName}, body=${msg.body.toUHexString()}"
             }
         }
         super.channelRead(ctx, msg)
@@ -156,7 +187,10 @@ private fun attachNetReplayWView(channel: Channel) {
     fun Component.onClick(handle: () -> Unit) {
         addMouseListener(object : MouseAdapter() {
             override fun mouseClicked(e: MouseEvent?) {
-                runCatching(handle).onFailure { logger.error(it) }
+                runCatching(handle).onFailure { err ->
+                    NetReplayHelperSettings.logger_console.error(err)
+                    NetReplayHelperSettings.logger_file.error(err)
+                }
             }
         })
     }
@@ -192,14 +226,11 @@ private fun attachNetReplayWView(channel: Channel) {
     frame.setLocationRelativeTo(null)
     frame.isVisible = true
 
-    channel.pipeline().addFirst(object : ChannelInboundHandlerAdapter() {
-        override fun channelInactive(ctx: ChannelHandlerContext?) {
-            SwingUtilities.invokeLater {
-                frame.dispose()
-            }
-            super.channelInactive(ctx)
+    channel.closeFuture().addListener {
+        SwingUtilities.invokeLater {
+            frame.dispose()
         }
-    })
+    }
 
 }