diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 5f90b2d..f3cc527 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -177,6 +177,8 @@ jobs: env: # Lets Gradle resolve the private mcef-nova artifact from GitHub Packages. Works once the # package grants read access to this repo; until then the vendor/maven fallback covers it. + # mcef-nova v1.0.2 (tag v1.0.2, published to GitHub Packages). The vendor/maven copy has the + # same classes as a build of that tag. GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_NAME: ${{ needs.decide.outputs.version_name }} diff --git a/build.gradle.kts b/build.gradle.kts index d2f32e9..bb8477d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -251,7 +251,7 @@ dependencies { } // Version-agnostic CEF fork: a plain library (no net.minecraft), so no Loom remapping. - implementation("com.github.FPSMasterTeam:mcef-nova:1.0.1") + implementation("com.github.FPSMasterTeam:mcef-nova:1.0.2") // modRuntimeOnly(group = "maven.modrinth", name = "ImmediatelyFast", version = "1.14.2+1.21.11-fabric") // modApi(group = "maven.modrinth", name = "sodium", version = "mc1.21.11-0.8.12-fabric") @@ -319,7 +319,7 @@ dependencies { bundledRuntime("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.4.0") bundledRuntime("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") bundledRuntime("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.11.0") - bundledRuntime("com.github.FPSMasterTeam:mcef-nova:1.0.1") + bundledRuntime("com.github.FPSMasterTeam:mcef-nova:1.0.2") bundledRuntime("io.github.vlouboos:standaloneevent-common:1.6") // 只打 Cadence 自己的类:它的两个传递依赖 kotlin-stdlib(上面已 bundle)与 gson(MC 自带) // 都已在运行时就位,transitive 打进来只会重复/覆盖。 diff --git a/src/main/java/top/fpsmaster/mixin/impl/MixinGameRenderer.java b/src/main/java/top/fpsmaster/mixin/impl/MixinGameRenderer.java index 1860c91..ed3a687 100644 --- a/src/main/java/top/fpsmaster/mixin/impl/MixinGameRenderer.java +++ b/src/main/java/top/fpsmaster/mixin/impl/MixinGameRenderer.java @@ -75,11 +75,14 @@ public void hookGameRender(CallbackInfo callbackInfo) { top.fpsmaster.ui.kit.NovaBlur.INSTANCE.beginFrame(); if (MCEF.INSTANCE.isInitialized()) { try { - // On Windows/Linux CEF runs on its own message-loop thread (mcef-nova's - // CefMessageLoopThread) and this only uploads the frames that thread buffered — - // the render thread never blocks in CefDoMessageLoopWork again (that block, ~13ms - // per frame while the browser was idle, was the "40 fps until the ClickGUI opens" - // Windows bug). On macOS this still pumps CEF on this thread, then uploads. + // With hardware acceleration OFF (default), Windows/Linux CEF runs on its own + // message-loop thread (mcef-nova's CefMessageLoopThread) and this only uploads the + // frames that thread buffered — the render thread never blocks in + // CefDoMessageLoopWork (that block, ~13ms per frame while the browser was idle, was + // the "40 fps until the ClickGUI opens" Windows bug). With acceleration ON + // (mcef.pumpOnRenderThread) — and always on macOS — this pumps CEF on this thread + // first (CEF's accelerated OSR only delivers frames continuously in that mode), + // then adopts/uploads. MCEF.INSTANCE.update(); } catch (Exception e) { LogUtil.logger.error("Failed to update browser frames", e); diff --git a/src/main/kotlin/top/fpsmaster/Client.kt b/src/main/kotlin/top/fpsmaster/Client.kt index b74111b..f78e095 100644 --- a/src/main/kotlin/top/fpsmaster/Client.kt +++ b/src/main/kotlin/top/fpsmaster/Client.kt @@ -57,6 +57,14 @@ class Client : ModInitializer { return } hostConfigured = true + // mcef-nova 1.0.2 only enables CEF's shared-texture (zero-copy) paint when its message loop is + // pumped on the render thread: on its dedicated CEF thread, accelerated OSR stalls after one + // frame. The pump mode is fixed at CEF init, so decide it here from the persisted toggle AND + // actual platform/GPU support — a stale enabled setting can travel to an unsupported machine, + // where pumping on the render thread would only bring back CEF's message-loop cost per frame. + if (ClientSettings.hardwareAcceleration.getValue() && BasicBrowser.isAccelerationAvailable()) { + System.setProperty("mcef.pumpOnRenderThread", "true") + } // Legacy immediate-mode (<1.21.5) draws the imported zero-copy accel texture through a plain // position_tex shader, so have mcef R/B-swizzle imported accel textures (CEF ships BGRA imported // as RGBA8) at import time. 1.21.5+ swaps R/B in a dedicated shader instead and leaves this off. 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 1eb932d..c87abfd 100644 --- a/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt +++ b/src/main/kotlin/top/fpsmaster/module/impl/auxiliary/Replay.kt @@ -188,14 +188,6 @@ 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 } diff --git a/src/main/kotlin/top/fpsmaster/replay/DirectorSession.kt b/src/main/kotlin/top/fpsmaster/replay/DirectorSession.kt index 9395bbd..2ac94c9 100644 --- a/src/main/kotlin/top/fpsmaster/replay/DirectorSession.kt +++ b/src/main/kotlin/top/fpsmaster/replay/DirectorSession.kt @@ -76,19 +76,27 @@ class DirectorSession( applyCamera(source) } - /** Client thread, once per frame. */ + /** Client thread, once per tick. */ fun tick() { + // The length arrives from a background index a moment after opening. + project.ensureDuration(sourceDuration()) + // Whether the frame drawn since the last tick showed a fully loaded world. + val settled = !playback.isCatchingUp + playback.tick() val running = export if (running != null) { - running.step() + running.step(settled) if (running.isFinished) { export = null } return } pollRollKeys() - playback.tick() - advanceOutputClock() + // While the world is still loading towards the playhead, positionMillis is where it is + // going, not where it is; following clip edges off it would seek again mid-load. + if (!playback.isCatchingUp) { + advanceOutputClock() + } applyCamera(playback.positionMillis) } @@ -275,10 +283,17 @@ class DirectorSession( scrubTo(0L) } - fun step() { + /** + * [settled]: the world had caught up before this tick, so the frame drawn since shows the + * position set last time. A seek that has not finished loading skips capture until then. + */ + fun step(settled: Boolean) { if (isFinished) { return } + if (!settled || playback.isCatchingUp) { + return + } if (frameIndex >= plan.frameCount) { finish() return diff --git a/src/main/kotlin/top/fpsmaster/replay/NovaReplayFile.kt b/src/main/kotlin/top/fpsmaster/replay/NovaReplayFile.kt index f27c509..7bcef2c 100644 --- a/src/main/kotlin/top/fpsmaster/replay/NovaReplayFile.kt +++ b/src/main/kotlin/top/fpsmaster/replay/NovaReplayFile.kt @@ -2,15 +2,23 @@ package top.fpsmaster.replay import java.io.BufferedInputStream import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream import java.io.Closeable +import java.io.DataInput import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException +import java.io.InputStream +import java.io.RandomAccessFile +import java.util.zip.CRC32 +import java.util.zip.DataFormatException +import java.util.zip.Deflater import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream +import java.util.zip.Inflater /** The file is not a NOVAREPL container, or its layout is one this build cannot read. */ class ReplayFormatException(message: String) : IOException(message) @@ -28,12 +36,26 @@ class ReplayVersionMismatchException( " (this client is $currentVersion)" ) +/** + * What a scan of a whole recording found, without keeping any of it: how long it runs and how many + * records it holds. Built off the client thread ([NovaReplayFile.scanIndex]) when a replay opens. + */ +data class ReplayIndex(val durationMillis: Int, val recordCount: Long, val blockCount: Int) + /** * On-disk format for a recorded clientbound packet stream. * + * Format 2 (written by this build): + * ``` + * header UTF "NOVAREPL" | int 2 | UTF minecraftVersion | long wall-clock start + * recording profile (see RecordingProfile) -- uncompressed + * block int "NRBK" | int minMillis | int maxMillis | int recordCount + * int rawLength | int storedLength | int crc32(stored) | stored -- zlib + * ``` + * Format 1 (read only): the same header and records, the whole file one gzip stream. + * + * Inside a block, and in format 1, records are: * ``` - * header UTF "NOVAREPL" | int formatVersion | UTF minecraftVersion | long wall-clock start - * recording profile (see RecordingProfile) * record byte type | int millis-since-start | ... * type 0 int payload length | payload bytes * type 1 double x,y,z | float yaw,pitch | byte flags @@ -41,22 +63,30 @@ class ReplayVersionMismatchException( * type 3 int payload length | payload bytes * ``` * - * A packet payload is whatever [ReplayPacketAdapter] produced on the recording client: the packet - * id and body as that Minecraft version's own codec writes them. Nothing here interprets it, which - * is why the header's `minecraftVersion` is a hard gate on [openForRead] rather than advice. + * Blocks exist so a recording can be indexed without inflating it: [scanIndex] reads block headers + * and skips the bodies, so the duration of an hour-long file is known in milliseconds. A block never + * splits a record. Each flush closes a block, so a crash still costs only what came after the last + * flush, and a torn last block fails its length or CRC check and reads as the end of the recording. + * + * A packet payload is whatever [ReplayPacketAdapter][top.fpsmaster.replay.adapter.ReplayPacketAdapter] + * produced on the recording client: the packet id and body as that Minecraft version's own codec + * writes them. Nothing here interprets it, which is why the header's `minecraftVersion` is a hard + * gate on [openForRead] rather than advice. * * Type 1 and 2 are the recording client's own player. A server never sends you your own movement or * equipment, so replaying a session with the recorder visible in it means capturing those * separately. Type 3 is the world snapshot taken when recording starts mid-session: chunks and * entities already loaded are never re-sent, so without it the replay opens on empty space. - * - * Gzipped with `syncFlush`, so a periodic flush emits a complete deflate block: a crash mid-session - * costs everything since the last flush rather than the whole file. */ object NovaReplayFile { const val MAGIC: String = "NOVAREPL" - const val FORMAT_VERSION: Int = 1 + + /** The layout this build writes. */ + const val FORMAT_VERSION: Int = 2 + + /** The original single-gzip-stream layout. Still read; never written. */ + const val LEGACY_FORMAT_VERSION: Int = 1 const val TYPE_PACKET: Int = 0 const val TYPE_LOCAL_PLAYER: Int = 1 @@ -71,103 +101,174 @@ object NovaReplayFile { /** Sanity bound on one record. Comfortably above a full chunk packet, far below a bad length. */ private const val MAX_PAYLOAD = 8 shl 20 + /** A block is closed once its records reach this many bytes uncompressed. */ + internal const val BLOCK_TARGET_BYTES = 256 shl 10 + + /** One block holds at most a target's worth plus one maximal record; anything larger is damage. */ + private const val MAX_BLOCK_BYTES = BLOCK_TARGET_BYTES + MAX_PAYLOAD + 64 + + private const val BLOCK_TAG = 0x4E52424B // "NRBK" + private const val BLOCK_HEADER_BYTES = 7 * 4 + fun openForWrite( file: File, minecraftVersion: String, startMillis: Long, profile: RecordingProfile, ): Writer { - val out = DataOutputStream( - GZIPOutputStream(BufferedOutputStream(FileOutputStream(file), 1 shl 16), true) - ) - out.writeUTF(MAGIC) - out.writeInt(FORMAT_VERSION) - out.writeUTF(minecraftVersion) - out.writeLong(startMillis) - profile.write(out) - out.flush() + val out = DataOutputStream(BufferedOutputStream(FileOutputStream(file), 1 shl 16)) + try { + out.writeUTF(MAGIC) + out.writeInt(FORMAT_VERSION) + out.writeUTF(minecraftVersion) + out.writeLong(startMillis) + profile.write(out) + out.flush() + } catch (failure: IOException) { + out.close() + throw failure + } return Writer(out) } - class Writer(private val out: DataOutputStream) : Closeable { - fun write(record: ReplayRecord) { - out.writeByte(record.type) - out.writeInt(record.millis) - when (record.type) { - TYPE_LOCAL_PLAYER -> { - out.writeDouble(record.x) - out.writeDouble(record.y) - out.writeDouble(record.z) - out.writeFloat(record.yaw) - out.writeFloat(record.pitch) - out.writeByte(record.flags) - } - - TYPE_LOCAL_EQUIPMENT -> { - out.writeByte(record.slot) - writePayload(record.payload) - } + /** Buffers records into a block and writes the block, compressed, when it fills or is flushed. */ + class Writer internal constructor(private val out: DataOutputStream) : Closeable { + private val raw = ExposedBuffer(BLOCK_TARGET_BYTES + (BLOCK_TARGET_BYTES shr 2)) + private val records = DataOutputStream(raw) + private val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) + private val crc = CRC32() + private var stored = ByteArray(BLOCK_TARGET_BYTES shr 1) + private var count = 0 + private var minMillis = 0 + private var maxMillis = 0 - else -> writePayload(record.payload) + fun write(record: ReplayRecord) { + writeRecord(records, record) + if (count == 0) { + minMillis = record.millis + maxMillis = record.millis + } else { + minMillis = minOf(minMillis, record.millis) + maxMillis = maxOf(maxMillis, record.millis) + } + count++ + if (raw.size() >= BLOCK_TARGET_BYTES) { + writeBlock() } } - private fun writePayload(payload: ByteArray?) { - val bytes = payload ?: ByteArray(0) - out.writeInt(bytes.size) - out.write(bytes) + /** Closes the current block and pushes it to the file, so everything so far is readable. */ + fun flush() { + writeBlock() + out.flush() } - fun flush() = out.flush() + override fun close() { + try { + writeBlock() + } finally { + deflater.end() + out.close() + } + } - override fun close() = out.close() + private fun writeBlock() { + if (count == 0) { + return + } + deflater.reset() + deflater.setInput(raw.array(), 0, raw.size()) + deflater.finish() + var length = 0 + while (!deflater.finished()) { + if (length == stored.size) { + stored = stored.copyOf(stored.size * 2) + } + length += deflater.deflate(stored, length, stored.size - length) + } + crc.reset() + crc.update(stored, 0, length) + out.writeInt(BLOCK_TAG) + out.writeInt(minMillis) + out.writeInt(maxMillis) + out.writeInt(count) + out.writeInt(raw.size()) + out.writeInt(length) + out.writeInt(crc.value.toInt()) + out.write(stored, 0, length) + raw.reset() + count = 0 + } } /** - * Opens a recording for playback, refusing anything this client cannot decode. + * Opens a recording for playback, refusing anything this client cannot decode. Reads either + * format; records come back the same way from both. * - * @throws ReplayFormatException the file is not a NOVAREPL container of this layout + * @throws ReplayFormatException the file is not a NOVAREPL container of a known layout * @throws ReplayVersionMismatchException it is, but from another Minecraft version */ fun openForRead(file: File, currentMinecraftVersion: String): Reader { - val input = DataInputStream(GZIPInputStream(BufferedInputStream(FileInputStream(file), 1 shl 16))) - val header = try { - readHeader(input) - } catch (failure: IOException) { - input.close() - if (failure is ReplayFormatException) { - throw failure - } - throw ReplayFormatException("${file.name} ends inside its header") + val opened = openContainer(file) + if (opened.header.minecraftVersion != currentMinecraftVersion) { + opened.records.close() + throw ReplayVersionMismatchException(opened.header.minecraftVersion, currentMinecraftVersion) } - if (header.minecraftVersion != currentMinecraftVersion) { - input.close() - throw ReplayVersionMismatchException(header.minecraftVersion, currentMinecraftVersion) - } - return Reader(input, header) + return Reader(opened.records, opened.header) } /** Header only, for listing recordings without committing to playing them. */ - fun readHeader(file: File): Header = - DataInputStream(GZIPInputStream(BufferedInputStream(FileInputStream(file), 1 shl 16))) - .use { readHeader(it) } + fun readHeader(file: File): Header = openContainer(file).let { opened -> + opened.records.close() + opened.header + } - private fun readHeader(input: DataInputStream): Header { - val magic = try { - input.readUTF() - } catch (malformed: IOException) { - throw ReplayFormatException("not a Nova replay: unreadable magic") - } - if (magic != MAGIC) { - throw ReplayFormatException("not a Nova replay: magic was \"$magic\"") + /** + * Walks the whole recording for its length. Format 2 reads block headers only; format 1 has no + * blocks, so it is inflated record by record — which is why this runs off the client thread. + * Stops early, with what it has, once [cancelled] says so. + */ + fun scanIndex(file: File, cancelled: () -> Boolean = { false }): ReplayIndex { + if (isLegacy(file)) { + return openContainer(file).let { opened -> + Reader(opened.records, opened.header).use { reader -> + var duration = 0 + var count = 0L + while (!cancelled()) { + val record = reader.read() ?: break + duration = maxOf(duration, record.millis) + count++ + } + ReplayIndex(duration, count, 0) + } + } } - val formatVersion = input.readInt() - if (formatVersion != FORMAT_VERSION) { - throw ReplayFormatException( - "replay format version $formatVersion, this build reads $FORMAT_VERSION" - ) + RandomAccessFile(file, "r").use { input -> + readHeader(input, FORMAT_VERSION) + var duration = 0 + var count = 0L + var blocks = 0 + val length = input.length() + while (!cancelled() && length - input.filePointer >= BLOCK_HEADER_BYTES) { + if (input.readInt() != BLOCK_TAG) { + break + } + input.readInt() // minMillis + val maxMillis = input.readInt() + val records = input.readInt() + input.readInt() // rawLength + val storedLength = input.readInt() + input.readInt() // crc + if (storedLength < 0 || input.filePointer + storedLength > length) { + break // torn last block: the stream reader stops here as well + } + input.seek(input.filePointer + storedLength) + duration = maxOf(duration, maxMillis) + count += records + blocks++ + } + return ReplayIndex(duration, count, blocks) } - return Header(input.readUTF(), input.readLong(), RecordingProfile.read(input)) } data class Header( @@ -176,7 +277,7 @@ object NovaReplayFile { val profile: RecordingProfile, ) - class Reader(private val input: DataInputStream, val header: Header) : Closeable { + class Reader internal constructor(private val input: DataInputStream, val header: Header) : Closeable { /** * The next record, or null once no complete one remains. * @@ -224,4 +325,196 @@ object NovaReplayFile { override fun close() = input.close() } + + // ------------------------------------------------------------------ containers + + private class Opened(val header: Header, val records: DataInputStream) + + /** gzip's magic bytes; a format 2 file starts with the UTF length of "NOVAREPL" instead. */ + private fun startsWithGzip(input: InputStream): Boolean { + input.mark(2) + val first = input.read() + val second = input.read() + input.reset() + return first == 0x1f && second == 0x8b + } + + private fun isLegacy(file: File): Boolean = + BufferedInputStream(FileInputStream(file), 2).use { startsWithGzip(it) } + + private fun openContainer(file: File): Opened { + val buffered = BufferedInputStream(FileInputStream(file), 1 shl 16) + try { + if (startsWithGzip(buffered)) { + val gzip = DataInputStream(GZIPInputStream(buffered, 1 shl 16)) + return Opened(readHeader(file, gzip, LEGACY_FORMAT_VERSION), gzip) + } + val plain = DataInputStream(buffered) + val header = readHeader(file, plain, FORMAT_VERSION) + return Opened(header, DataInputStream(BlockInputStream(plain))) + } catch (failure: IOException) { + buffered.close() + if (failure is ReplayFormatException) { + throw failure + } + throw ReplayFormatException("${file.name} ends inside its header") + } + } + + private fun readHeader(file: File, input: DataInput, expectedVersion: Int): Header = try { + readHeader(input, expectedVersion) + } catch (failure: IOException) { + if (failure is ReplayFormatException) throw failure + throw ReplayFormatException("${file.name} ends inside its header") + } + + private fun readHeader(input: DataInput, expectedVersion: Int): Header { + val magic = try { + input.readUTF() + } catch (malformed: IOException) { + throw ReplayFormatException("not a Nova replay: unreadable magic") + } + if (magic != MAGIC) { + throw ReplayFormatException("not a Nova replay: magic was \"$magic\"") + } + val formatVersion = input.readInt() + if (formatVersion != expectedVersion) { + throw ReplayFormatException( + "replay format version $formatVersion in a format $expectedVersion container;" + + " this build reads $LEGACY_FORMAT_VERSION and $FORMAT_VERSION" + ) + } + return Header(input.readUTF(), input.readLong(), RecordingProfile.read(input)) + } + + internal fun writeRecord(out: DataOutputStream, record: ReplayRecord) { + out.writeByte(record.type) + out.writeInt(record.millis) + when (record.type) { + TYPE_LOCAL_PLAYER -> { + out.writeDouble(record.x) + out.writeDouble(record.y) + out.writeDouble(record.z) + out.writeFloat(record.yaw) + out.writeFloat(record.pitch) + out.writeByte(record.flags) + } + + TYPE_LOCAL_EQUIPMENT -> { + out.writeByte(record.slot) + writePayload(out, record.payload) + } + + else -> writePayload(out, record.payload) + } + } + + private fun writePayload(out: DataOutputStream, payload: ByteArray?) { + val bytes = payload ?: ByteArray(0) + out.writeInt(bytes.size) + out.write(bytes) + } + + private class ExposedBuffer(size: Int) : ByteArrayOutputStream(size) { + fun array(): ByteArray = buf + } + + /** + * The records of every block, inflated one block at a time and read as one stream. A block that + * is torn, fails its CRC or does not inflate to its stated length ends the stream there. + */ + private class BlockInputStream(private val source: DataInputStream) : InputStream() { + private val inflater = Inflater() + private val crc = CRC32() + private var block = ByteArray(0) + private var length = 0 + private var position = 0 + private var ended = false + + override fun read(): Int { + if (position >= length && !nextBlock()) { + return -1 + } + return block[position++].toInt() and 0xff + } + + override fun read(target: ByteArray, offset: Int, count: Int): Int { + if (count == 0) { + return 0 + } + if (position >= length && !nextBlock()) { + return -1 + } + val n = minOf(count, length - position) + System.arraycopy(block, position, target, offset, n) + position += n + return n + } + + private fun nextBlock(): Boolean { + while (!ended) { + val loaded = try { + loadBlock() + } catch (end: EOFException) { + false + } catch (damaged: DataFormatException) { + false + } + if (!loaded) { + ended = true + return false + } + if (length > 0) { + return true + } + } + return false + } + + private fun loadBlock(): Boolean { + if (source.readInt() != BLOCK_TAG) { + return false + } + source.readInt() // minMillis + source.readInt() // maxMillis + source.readInt() // recordCount + val rawLength = source.readInt() + val storedLength = source.readInt() + val expectedCrc = source.readInt() + if (rawLength < 0 || rawLength > MAX_BLOCK_BYTES || storedLength < 0 || storedLength > MAX_BLOCK_BYTES) { + return false + } + val stored = ByteArray(storedLength) + source.readFully(stored) + crc.reset() + crc.update(stored, 0, storedLength) + if (crc.value.toInt() != expectedCrc) { + return false + } + if (block.size < rawLength) { + block = ByteArray(rawLength) + } + inflater.reset() + inflater.setInput(stored) + var inflated = 0 + while (inflated < rawLength && !inflater.finished()) { + val n = inflater.inflate(block, inflated, rawLength - inflated) + if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) { + break + } + inflated += n + } + if (inflated != rawLength) { + return false + } + length = rawLength + position = 0 + return true + } + + override fun close() { + inflater.end() + source.close() + } + } } diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayCursor.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayCursor.kt new file mode 100644 index 0000000..ec677c5 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayCursor.kt @@ -0,0 +1,97 @@ +package top.fpsmaster.replay + +import java.io.Closeable + +/** + * Where playback stands in a recording, and the work of getting it somewhere else. + * + * A packet stream only runs forwards: any packet may be the one that placed the block on screen, so + * the only way back is to tear the world down ([rebuild]) and apply everything again from the start. + * What this changes over holding the whole recording in memory is *when* that work happens: a seek + * only moves [targetMillis], and [pump] applies records towards it within a time budget, a slice + * per tick. The records come from a [ReplayStream], so memory holds a bounded window ahead of the + * playhead rather than the recording. + * + * Minecraft-free on purpose, so seeking can be tested against a synthetic recording. + */ +class ReplayCursor( + private val openStream: () -> ReplayStream, + private val apply: (ReplayRecord) -> Unit, + private val rebuild: () -> Unit, +) : Closeable { + + private var stream: ReplayStream = openStream() + + /** Where playback has been asked to be. */ + var targetMillis: Int = 0 + private set + + /** The newest record applied so far; -1 before anything, even the records at zero, has been. */ + var appliedMillis: Int = -1 + private set + + /** Every record stamped at or before this has been applied. Trails [targetMillis] while loading. */ + var reachedMillis: Int = -1 + private set + + /** Times the world had to be rebuilt from the start. */ + var rebuilds: Int = 0 + private set + + val isCaughtUp: Boolean + get() = reachedMillis >= targetMillis + + /** Caught up with nothing left to read: playback has reached the end of the recording. */ + val isAtEnd: Boolean + get() = isCaughtUp && stream.isExhausted + + /** + * Moves the target. Behind anything already applied, that means rebuilding the world and + * reading again from the beginning; anywhere else it costs nothing until [pump]. + */ + fun seekTo(millis: Int) { + val target = millis.coerceAtLeast(0) + if (target < appliedMillis) { + stream.close() + rebuild() + stream = openStream() + appliedMillis = -1 + reachedMillis = -1 + rebuilds++ + } + targetMillis = target + } + + /** + * Applies records towards [targetMillis] until caught up, until the reader has nothing ready, + * or until [deadlineNanos] passes — whichever comes first. Always applies at least one ready + * record, so a tiny budget still makes progress. Returns whether it caught up. + */ + fun pump(deadlineNanos: Long, clock: () -> Long = System::nanoTime): Boolean { + while (reachedMillis < targetMillis) { + // Read "finished" before looking at the buffer: the reader adds its last record and then + // finishes, so finished-then-empty really is the end. + val finished = stream.isFinished + val next = stream.peek() + if (next == null) { + if (finished) { + reachedMillis = targetMillis + } + break + } + if (next.millis > targetMillis) { + reachedMillis = targetMillis + break + } + stream.poll() + apply(next) + appliedMillis = maxOf(appliedMillis, next.millis) + if (clock() - deadlineNanos >= 0) { + break + } + } + return isCaughtUp + } + + override fun close() = stream.close() +} diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt index 0eb19c9..f3605c9 100644 --- a/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayPlayback.kt @@ -10,6 +10,9 @@ import top.fpsmaster.replay.adapter.ReplayWorldAdapter import top.fpsmaster.screenCompat import java.io.File +import net.minecraft.client.gui.screens.Screen +import top.fpsmaster.setScreenCompat + /** * Plays a recording back on the client thread, into an isolated listener. * @@ -18,21 +21,21 @@ import java.io.File * would have handed them over anyway. The vanilla handlers then build the world exactly as they did * while it was being recorded. * - * Records are held in memory. Seeking backwards means tearing the world down and replaying from the - * start — a packet stream has no other way back, since any packet may be the one that placed the - * block you are looking at — and a stream that had to be re-read from disk for every scrub would - * make the director unusable. + * Nothing here holds the recording. A [ReplayStream] reads it on its own thread into a bounded + * window ahead of the playhead, a [ReplayCursor] applies records from that window a time-budgeted + * slice per tick, and the length comes from a [ReplayIndex] built on a background thread. Opening, + * and seeking — which backwards still means rebuilding the world from the start, since a packet + * stream cannot run in reverse — therefore never stall a frame; until the world has caught up with + * the playhead [isLoading] says so, and the clock waits. */ class ReplayPlayback private constructor( + private val file: File, 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. */ - var positionMillis: Int = 0 - private set + /** Position on the recording's own timeline: where playback is, or is loading towards. */ + val positionMillis: Int + get() = cursor?.targetMillis ?: 0 var isPaused: Boolean = true private set @@ -42,22 +45,65 @@ class ReplayPlayback private constructor( field = value.coerceIn(SPEED_MIN, SPEED_MAX) } - val durationMillis: Int = records.lastOrNull()?.millis ?: 0 + /** Length of the recording; 0 until the background index lands, see [isIndexed]. */ + @Volatile + var durationMillis: Int = 0 + private set + + @Volatile + var isIndexed: Boolean = false + private set /** True while playback owns the client's world. */ val isOpen: Boolean get() = session != null + /** + * The world is behind the playhead, or a loading screen vanilla put up while rebuilding it is + * still waiting to be taken down. Nothing on screen is the playhead's moment yet. + */ + val isCatchingUp: Boolean + get() = cursor?.isCaughtUp == false || restorePending + + /** Behind by enough to be worth telling the viewer, rather than a tick's worth of packets. */ + val isLoading: Boolean + get() { + val active = cursor ?: return false + return restorePending || active.targetMillis - active.reachedMillis > LOADING_THRESHOLD_MILLIS + } + + /** How far loading has got towards the playhead, 0..1. */ + val loadingProgress: Float + get() { + val active = cursor ?: return 0f + if (active.targetMillis <= 0) { + return if (active.isCaughtUp) 1f else 0f + } + return (active.appliedMillis.coerceAtLeast(0).toFloat() / active.targetMillis).coerceIn(0f, 1f) + } + private var session: ReplayWorldAdapter.IsolatedSession? = null - private var cursor = 0 + private var cursor: ReplayCursor? = null private var lastFrameNanos = 0L + private var lastPumpNanos = 0L + + @Volatile + private var indexCancelled = false + + /** + * Opening and rebuilding go through vanilla's login handling, which puts up a level-loading + * screen. Once the world has caught up it is replaced by [restoreScreen]: the world itself after + * opening, whatever was open before a rebuild (the replay controls, mid-scrub). + */ + private var restorePending = false + private var restoreScreen: Screen? = null /** Set when the recorded player's own position should drive the camera. */ var followRecorder: Boolean = true /** - * Opens the world and applies everything stamped at time zero — the session packets and the - * terrain snapshot — before the first frame is drawn. + * Leaves whatever world is open and starts loading this one: the session packets and terrain + * snapshot stamped at time zero arrive over the next ticks, behind a loading screen. */ fun open() { if (session != null) { @@ -65,6 +111,11 @@ class ReplayPlayback private constructor( } leaveCurrentSession() startIsolatedSession() + cursor = ReplayCursor(::openStream, ::applyRecord, ::rebuild) + lastFrameNanos = System.nanoTime() + lastPumpNanos = lastFrameNanos + armRestore(null) + startIndexing() } /** @@ -95,10 +146,31 @@ class ReplayPlayback private constructor( 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) + } + + private fun openStream(): ReplayStream = ReplayStream( + { NovaReplayFile.openForRead(file, header.minecraftVersion) }, + windowBytes(), + ) + + private fun startIndexing() { + val worker = Thread({ + try { + val index = NovaReplayFile.scanIndex(file) { indexCancelled } + if (!indexCancelled) { + durationMillis = index.durationMillis + isIndexed = true + logger.info( + "[replay] indexed ${file.name}: ${index.recordCount} records," + + " ${index.durationMillis / 1000}s, ${index.blockCount} blocks" + ) + } + } catch (failure: Exception) { + logger.warn("[replay] could not index ${file.name}: ${failure.message}") + } + }, "nova-replay-index") + worker.isDaemon = true + worker.start() } fun play() { @@ -110,83 +182,114 @@ class ReplayPlayback private constructor( isPaused = true } - /** Client thread, once per frame. Advances the clock and applies whatever is now due. */ + /** Client thread, once per tick. Advances the clock and applies a slice of whatever is now due. */ fun tick() { val active = session ?: return + val playhead = cursor ?: return active.discardOutbound() - if (isPaused) { - lastFrameNanos = System.nanoTime() - return - } val now = System.nanoTime() - val deltaMillis = ((now - lastFrameNanos) / 1_000_000L * speed).toInt() - lastFrameNanos = now - if (deltaMillis <= 0) { - return + // The clock waits while the world catches up: otherwise the playhead would run on ahead + // of a world that is still loading, and the viewer would miss whatever happened meanwhile. + if (isPaused || isCatchingUp) { + lastFrameNanos = now + } else { + val deltaMillis = ((now - lastFrameNanos) / 1_000_000L * speed).toInt() + lastFrameNanos = now + if (deltaMillis > 0) { + var target = playhead.targetMillis + deltaMillis + if (isIndexed) { + target = minOf(target, durationMillis) + } + playhead.seekTo(target) + } } - applyUntil(positionMillis + deltaMillis) - if (positionMillis >= durationMillis) { + pump() + if (!isPaused && !isCatchingUp && + (playhead.isAtEnd || (isIndexed && playhead.targetMillis >= durationMillis)) + ) { pause() } } /** - * Moves to [millis]. Forwards this replays the packets in between as fast as it can; backwards - * it rebuilds the world from the start, because a packet stream cannot be run in reverse. + * Moves to [millis]. Forwards this applies the packets in between; backwards it rebuilds the + * world from the start, because a packet stream cannot be run in reverse. Either way only a + * budgeted slice runs now and [tick] carries on with the rest — see [isCatchingUp]. */ fun seekTo(millis: Int) { - val target = millis.coerceIn(0, durationMillis) - if (target < positionMillis) { - rebuild() - } - applyUntil(target) - // seek 花掉的墙钟不是「回放走过的时间」:rebuild 要拆掉世界、再从 0 毫秒同步重放 - // 整条包流,几秒是常事。不在这儿把时钟重新对齐的话,下一帧算出来的 delta 就是这 - // 几秒乘上 speed,播放头一步跨过好几个片段,又触发一次往回 seek——高倍速加密集 - // 切分时会自锁成「每帧重建一次世界」的死循环。 + val playhead = cursor ?: return + val target = if (isIndexed) millis.coerceIn(0, durationMillis) else millis.coerceAtLeast(0) + playhead.seekTo(target) + pump() + // seek 花掉的墙钟不是「回放走过的时间」。不在这儿把时钟重新对齐的话,下一帧算出来的 + // delta 就是这段时间乘上 speed,播放头一步跨过好几个片段,又触发一次往回 seek。 lastFrameNanos = System.nanoTime() } fun close() { + indexCancelled = true + cursor?.close() + cursor = null session?.let { ReplayWorldAdapter.close(it) } session = null + restorePending = false + restoreScreen = null // reset(), not clear(): clear() deliberately keeps the hand-set roll (an empty camera track // runs through it every tick), and leaving the replay is where that tilt has to go. DirectorRenderAdapter.reset() } - /** The recorder's pose at [millis], for the camera to follow and for keyframing against. */ - fun recorderPoseAt(millis: Int): ReplayRecord? = records - .lastOrNull { it.type == NovaReplayFile.TYPE_LOCAL_PLAYER && it.millis <= millis } + /** + * Applies records for at most a share of the wall time since the last call, so catching up + * after a seek leaves frames being drawn — including when the client runs several ticks in + * one frame to catch up with itself, where the share comes out at the floor. + */ + private fun pump() { + val playhead = cursor ?: return + val now = System.nanoTime() + val budget = ((now - lastPumpNanos) * PUMP_SHARE_PERCENT / 100).coerceIn(PUMP_MIN_NANOS, PUMP_MAX_NANOS) + lastPumpNanos = now + playhead.pump(now + budget) + restoreScreenWhenLoaded() + } + /** Cursor callback: the playhead went backwards, so the world starts over. */ private fun rebuild() { - val active = session ?: return - ReplayWorldAdapter.close(active) + armRestore(mc.screenCompat) + session?.let { ReplayWorldAdapter.close(it) } session = null startIsolatedSession() } - private fun applyUntil(target: Int) { - val active = session ?: return - val registries = active.registries - while (cursor < records.size && records[cursor].millis <= target) { - apply(active, records[cursor], registries) - cursor++ + private fun armRestore(screen: Screen?) { + // A rebuild while an earlier one is still loading keeps the first one's screen: what is + // open now is the loading screen that rebuild put up. + if (!restorePending) { + restorePending = true + restoreScreen = screen + } + } + + private fun restoreScreenWhenLoaded() { + if (!restorePending || cursor?.isCaughtUp != true || mc.level == null) { + return + } + restorePending = false + val target = restoreScreen + restoreScreen = null + if (mc.screenCompat !== target) { + mc.setScreenCompat(target) } - positionMillis = target } - private fun apply( - active: ReplayWorldAdapter.IsolatedSession, - record: ReplayRecord, - registries: net.minecraft.core.RegistryAccess, - ) { + private fun applyRecord(record: ReplayRecord) { + val active = session ?: return if (record.type == NovaReplayFile.TYPE_LOCAL_PLAYER) { applyRecorderPose(record) return } val payload = record.payload ?: return - val packet = ReplayPacketAdapter.decode(payload, registries) ?: return + val packet = ReplayPacketAdapter.decode(payload, active.registries) ?: return try { ReplayWorldAdapter.dispatch(active, packet) } catch (failure: Throwable) { @@ -215,34 +318,23 @@ class ReplayPlayback private constructor( const val SPEED_MIN: Float = 0.1f const val SPEED_MAX: Float = 8f + /** Behind the playhead by more than this, the overlay shows loading instead of nothing. */ + private const val LOADING_THRESHOLD_MILLIS = 250 + + private const val PUMP_SHARE_PERCENT = 60L + private const val PUMP_MIN_NANOS = 2_000_000L + private const val PUMP_MAX_NANOS = 40_000_000L + /** - * Reads a recording into memory. + * Opens a recording for playback. Reads the header only — the records stream in once + * [open] is called. * * @throws ReplayVersionMismatchException the file was recorded on another Minecraft version * @throws ReplayFormatException it is not a recording this build can read */ 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() + ClipRing.RECORD_OVERHEAD - if (bytes > limit) { - truncated = true - logger.warn( - "[replay] ${file.name} is larger than ${limit shr 20}MB;" + - " playing back the first ${records.size} records" - ) - break - } - } - } - return ReplayPlayback(reader.header, records, truncated) + val header = NovaReplayFile.openForRead(file, ReplayPacketAdapter.minecraftVersion).use { it.header } + return ReplayPlayback(file, header) } /** Header only, for listing recordings without reading them. */ @@ -253,9 +345,9 @@ class ReplayPlayback private constructor( } /** - * 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. + * How much of a recording is read ahead of the playhead: a thirty-second of the heap, between + * 8 and 64 MB. Whatever the recording's length, this is all of it that is in memory at once. */ - fun maxLoadedBytes(): Long = (Runtime.getRuntime().maxMemory() / 4).coerceIn(64L shl 20, 512L shl 20) + fun windowBytes(): Long = (Runtime.getRuntime().maxMemory() / 32).coerceIn(8L shl 20, 64L shl 20) } } diff --git a/src/main/kotlin/top/fpsmaster/replay/ReplayStream.kt b/src/main/kotlin/top/fpsmaster/replay/ReplayStream.kt new file mode 100644 index 0000000..7c3b3e2 --- /dev/null +++ b/src/main/kotlin/top/fpsmaster/replay/ReplayStream.kt @@ -0,0 +1,104 @@ +package top.fpsmaster.replay + +import java.io.Closeable + +/** + * Reads a recording ahead of playback on its own thread, holding at most [maxBufferedBytes] of + * records that have been read but not yet consumed. + * + * This is the whole of what playback keeps in memory: records are dropped as soon as they are + * applied, and the reader waits whenever the window is full. One record larger than the window is + * still let through on its own, so a big chunk packet cannot wedge the stream. + */ +class ReplayStream( + open: () -> NovaReplayFile.Reader, + private val maxBufferedBytes: Long, + threadName: String = "nova-replay-stream", +) : Closeable { + + private val lock = Object() + private val buffer = ArrayDeque() + private var bufferedBytes = 0L + + @Volatile + private var closed = false + + /** Set once the reader has nothing more to add: end of file, a truncated tail, or a failure. */ + @Volatile + var isFinished: Boolean = false + private set + + /** Why reading stopped early, if it did. A truncated tail is not a failure. */ + @Volatile + var failure: Throwable? = null + private set + + /** High-water mark of [bufferedBytes], for the memory bound to be checked against. */ + @Volatile + var peakBufferedBytes: Long = 0L + private set + + private val thread = Thread({ run(open) }, threadName).apply { + isDaemon = true + start() + } + + /** The next record if one has been read, without waiting for the reader. */ + fun peek(): ReplayRecord? = synchronized(lock) { buffer.firstOrNull() } + + fun poll(): ReplayRecord? = synchronized(lock) { + val record = buffer.removeFirstOrNull() ?: return null + bufferedBytes -= cost(record) + lock.notifyAll() + record + } + + /** True once every record has been read and consumed. */ + val isExhausted: Boolean + get() = isFinished && synchronized(lock) { buffer.isEmpty() } + + override fun close() { + closed = true + synchronized(lock) { + buffer.clear() + bufferedBytes = 0L + lock.notifyAll() + } + thread.interrupt() + } + + private fun run(open: () -> NovaReplayFile.Reader) { + try { + open().use { reader -> + while (!closed) { + val record = reader.read() ?: break + val size = cost(record) + synchronized(lock) { + while (!closed && buffer.isNotEmpty() && bufferedBytes + size > maxBufferedBytes) { + lock.wait() + } + if (closed) { + return + } + buffer.addLast(record) + bufferedBytes += size + if (bufferedBytes > peakBufferedBytes) { + peakBufferedBytes = bufferedBytes + } + } + } + } + } catch (interrupted: InterruptedException) { + // close(): nothing further is wanted. + } catch (failed: Throwable) { + failure = failed + } finally { + isFinished = true + } + } + + companion object { + /** What one buffered record costs: its payload plus the same overhead [ClipRing] charges. */ + fun cost(record: ReplayRecord): Long = (record.payload?.size ?: 0).toLong() + ClipRing.RECORD_OVERHEAD + } +} diff --git a/src/main/kotlin/top/fpsmaster/ui/NativeReplayScreen.kt b/src/main/kotlin/top/fpsmaster/ui/NativeReplayScreen.kt index 797b1ab..cd4dc9f 100644 --- a/src/main/kotlin/top/fpsmaster/ui/NativeReplayScreen.kt +++ b/src/main/kotlin/top/fpsmaster/ui/NativeReplayScreen.kt @@ -130,15 +130,10 @@ class NativeReplayScreen( return } if (Chrome.button(ui, x, y, 90f, Metrics.BTN_H, tr("replay.play"), Chrome.ButtonStyle.PRIMARY)) { + // 打开只读文件头,世界在后面几个 tick 里流式载入:原版先挂上载入地形的界面, + // 追上之后 ReplayPlayback 把界面收掉,直接落进世界,控制条交给 ReplayOverlay。 val failure = Replay.open(file) status = failure ?: tr("replay.status.playing").format(file.name) - // 只有真的进了世界才关界面:控制条挂在 ReplayOverlay 上,而它在 mc.player 为空时 - // 直接返回不画。从主菜单打开一个回放、世界还没起来就把界面关掉的话,玩家落到一张 - // 什么都没有的标题画面上——没有控制条,也没有刚才那个回放列表,只能以为点崩了。 - if (Replay.session != null && mc.level != null) { - // 直接掉进世界里看回放,控制条交给 ReplayOverlay,按 ESC 能把这个界面叫回来。 - mc.setScreenCompat(null) - } } } @@ -152,9 +147,12 @@ class NativeReplayScreen( val duration = session.outputDuration() val playback = session.playback + val loading = + if (playback.isLoading) " " + tr("replay.hud.loading").format((playback.loadingProgress * 100f).toInt()) + else "" ui.canvas().drawString( ui.font(10), - "${time(session.outputMillis)} / ${time(duration)} x${"%.2f".format(playback.speed)}", + "${time(session.outputMillis)} / ${time(duration)} x${"%.2f".format(playback.speed)}$loading", x, y, ui.theme().textPrimary(), diff --git a/src/main/kotlin/top/fpsmaster/ui/ReplayOverlay.kt b/src/main/kotlin/top/fpsmaster/ui/ReplayOverlay.kt index 26cc517..d04d280 100644 --- a/src/main/kotlin/top/fpsmaster/ui/ReplayOverlay.kt +++ b/src/main/kotlin/top/fpsmaster/ui/ReplayOverlay.kt @@ -82,15 +82,22 @@ object ReplayOverlay { val clock = "${time(session.outputMillis)} / ${time(duration)} x${"%.2f".format(session.playback.speed)}" val font = ui.font(10) ui.canvas().drawString(font, clock, x + 10f, PANEL_Y + 7f, ui.theme().textPrimary()) - if (session.playback.isPaused) { - val paused = Language.get("replay.hud.paused") + // 拖回去或刚打开时世界要从头重放到播放头,这段时间时钟停着,得让人知道不是卡死了。 + val playback = session.playback + val badge = when { + playback.isLoading -> + Language.get("replay.hud.loading").format((playback.loadingProgress * 100f).toInt()) + playback.isPaused -> Language.get("replay.hud.paused") + else -> null + } + if (badge != null) { val small = ui.font(9) ui.canvas().drawString( small, - paused, - x + w - 10f - small.measure(paused), + badge, + x + w - 10f - small.measure(badge), PANEL_Y + 8f, - ui.theme().danger(), + if (playback.isLoading) ui.theme().textSecondary() else ui.theme().danger(), ) } diff --git a/src/main/kotlin/top/fpsmaster/web/cef/AcceleratedBrowserTexture.kt b/src/main/kotlin/top/fpsmaster/web/cef/AcceleratedBrowserTexture.kt index d7bf31c..68f81a4 100644 --- a/src/main/kotlin/top/fpsmaster/web/cef/AcceleratedBrowserTexture.kt +++ b/src/main/kotlin/top/fpsmaster/web/cef/AcceleratedBrowserTexture.kt @@ -1,14 +1,237 @@ package top.fpsmaster.web.cef -// 26.2 defers zero-copy accelerated web-UI texture wrapping: TextureFormat→GpuFormat and the -// GlTexture constructor signature changed. Stub reports unsupported so the CEF bridge never takes the -// accelerated path on 26.2. [[nova-mc26-unobfuscated-build]] +// 26.2 accelerated web-UI texture bridge. Three 26.2 obstacles, each solved here: +// (1) GlTexture's protected ctor is `(int usage, String, GpuFormat, int w, int h, int depthOrLayers, +// int mipLevels, int glId, FrameBufferCache)` — usage FIRST, the wrapped GL id 8th. The +// FrameBufferCache comes reflectively off the GpuDevice's package-private GlDevice backend. +// We wrap mcef's imported external GL id with it as a copy SOURCE. +// (2) 26.2's deferred GUI renderer only samples textures the DEVICE created — an externally-wrapped id +// records into the render state but draws blank. So we GPU-copy the imported frame into a device +// texture (glCopyImageSubData, not the device copyTextureToTexture whose FBO blit rejects the +// memory-object source) and draw that. The CPU never touches the pixels. +// (3) The fixed RGBA blit has no swizzle, so a GL texture swizzle on the destination reads CEF's BGRA +// back as RGBA (shader-free). +// Frame delivery requires the CEF message loop to be pumped ON THE RENDER THREAD +// (-Dmcef.pumpOnRenderThread=true, set by Client.configureHost when hardware-acceleration is enabled); +// with mcef's dedicated CEF thread, CEF stops after a single accelerated frame. Zero-copy stays +// opt-in via the hardware-acceleration setting. +// [[nova-mc26-unobfuscated-build]] //? if >=26 { -/*class AcceleratedBrowserTexture { - fun reset() {} +/*import com.mojang.blaze3d.GpuFormat +import com.mojang.blaze3d.opengl.FrameBufferCache +import com.mojang.blaze3d.opengl.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.textures.FilterMode +import com.mojang.blaze3d.textures.GpuSampler +import com.mojang.blaze3d.textures.GpuTexture +import com.mojang.blaze3d.textures.GpuTextureView +import org.lwjgl.opengl.GL11 +import org.lwjgl.opengl.GL13 +import org.lwjgl.opengl.GL33 +import org.lwjgl.opengl.GL43 +import top.fpsmaster.logger +import java.lang.reflect.Constructor +import java.util.function.Supplier + +/** + * Draws mcef-nova's ZERO-COPY imported CEF GPU texture on 26.2 by GPU-copying it into a device-owned + * texture the deferred GUI blit can sample (see the file header for why a straight wrap draws blank). + * + * The imported id is mcef's — the [com.mojang.blaze3d.opengl.GlTexture] we wrap it in as the copy source + * MUST NEVER be closed (that would glDeleteTextures an id mcef still manages). Only our destination texture + * is ours to [close]. If wrapping ever fails on an unexpected blaze3d layout, [isSupported] flips false and + * the caller falls back to the CPU path. + */ +class AcceleratedBrowserTexture { + + private var srcTexId = 0 + private var srcTexture: GpuTexture? = null + private var dstTexture: GpuTexture? = null + private var dstView: GpuTextureView? = null + private var dstWidth = 0 + private var dstHeight = 0 + + /** Clamp-to-edge linear sampler (SamplerCache dedupes, so this getter is cheap per frame). */ + val sampler: GpuSampler + get() = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR, false) + + /** + * GPU-copy CEF's imported [texId] frame ([width]x[height]) into our device-owned texture and return + * its view. Returns null if wrapping isn't possible. + * + * Why a copy and not a straight wrap-and-draw: 26.2's deferred GUI renderer only samples textures the + * device created/owns — an externally-wrapped GL id (mcef's imported texture) records into the render + * state but draws blank at flush. So we wrap the external id as the copy SOURCE and blit it, GPU-side, + * into a device texture the deferred blit can sample. The CPU never touches the pixels; the CEF→GL + * import stays zero-copy and the BGRA→RGBA swap is a free GL texture swizzle on the destination. + */ + fun setup(texId: Int, width: Int, height: Int): GpuTextureView? { + if (texId == 0 || width <= 0 || height <= 0 || !isSupported) { + return null + } + return try { + copyIntoOwnedTexture(texId, width, height) + } catch (t: Throwable) { + logger.error("Failed to draw accelerated browser texture (id={}, {}x{})", texId, width, height, t) + supported = false + null + } + } + + private fun copyIntoOwnedTexture(texId: Int, width: Int, height: Int): GpuTextureView { + val device = RenderSystem.getDevice() + // Wrap mcef's imported external GL id as a GlTexture (copy SOURCE); cache while the id is unchanged. + if (texId != srcTexId || srcTexture == null) { + srcTexture = wrapExternalTexture(texId, width, height) + srcTexId = texId + } + // Device-owned destination the deferred GUI blit can actually sample; recreate on size change. Its + // GL swizzle reads CEF's BGRA bytes back as RGBA at sample time (no CPU swizzle, no shader). + if (dstTexture == null || dstWidth != width || dstHeight != height) { + close() + val created = device.createTexture( + Supplier { "FPSMaster browser accel" }, + GpuTexture.USAGE_TEXTURE_BINDING or GpuTexture.USAGE_COPY_DST, + GpuFormat.RGBA8_UNORM, width, height, 1, 1 + ) + dstTexture = created + dstView = device.createTextureView(created) + dstWidth = width + dstHeight = height + applyBgraSwizzle(created) + } + // Direct GPU image copy of CEF's imported frame into our texture. We use glCopyImageSubData (not + // the device's copyTextureToTexture, which drives an FBO blit — the imported memory-object texture + // can't be an FBO colour attachment, giving GL_INVALID_FRAMEBUFFER_OPERATION). Image copy needs no + // FBO and no bind, and the CPU never touches the pixels. + // Raw OpenGL reports most failures through its error flag instead of throwing. Isolate this + // call from any older error and turn a failed copy into an exception so setup() disables the + // accelerated path; BasicBrowser will recreate a software-rendered browser on the next frame. + while (GL11.glGetError() != GL11.GL_NO_ERROR) { + // Drain stale errors. + } + GL43.glCopyImageSubData( + glId(srcTexture!!), GL11.GL_TEXTURE_2D, 0, 0, 0, 0, + glId(dstTexture!!), GL11.GL_TEXTURE_2D, 0, 0, 0, 0, + width, height, 1 + ) + val copyError = GL11.glGetError() + check(copyError == GL11.GL_NO_ERROR) { + "glCopyImageSubData failed with GL error 0x${copyError.toString(16)}" + } + return dstView!! + } + + private fun wrapExternalTexture(texId: Int, width: Int, height: Int): GpuTexture { + // GlTexture(int usage, String label, GpuFormat, int width, int height, int depthOrLayers, + // int mipLevels, int glId, FrameBufferCache) — usage FIRST, the existing GL id 8th + // (matches LiquidBounce's DirectGlTexture super() call). Wraps the id, no glGen. + // frameBufferCache() lives on the package-private GlDevice backend; reach it reflectively. + return glTextureCtor().newInstance( + GpuTexture.USAGE_TEXTURE_BINDING or GpuTexture.USAGE_COPY_SRC, + "FPSMaster browser accel src", + GpuFormat.RGBA8_UNORM, + width, + height, + 1, + 1, + texId, + frameBufferCache() + ) as GpuTexture + } + + /** + * Tear down our device-owned copy texture. NEVER touches the source GL id — that's mcef's (closing it + * would glDeleteTextures an id mcef still manages). Called on size change and browser teardown. + */ + fun close() { + dstView?.close() + dstView = null + dstTexture?.close() + dstTexture = null + dstWidth = 0 + dstHeight = 0 + } + + /** Browser teardown: drop our copy texture and forget the external id. */ + fun reset() { + close() + srcTexId = 0 + srcTexture = null + } + + /** + * Swap the destination texture's R/B channels via a GL texture swizzle so CEF's BGRA copy samples as + * RGBA — the free, shader-less BGRA fix (the fixed GUI blit has no swizzle). glId() is public but its + * declaring class (GlTexture) is package-private, so the reflective invoke needs setAccessible. + */ + private fun applyBgraSwizzle(texture: GpuTexture) { + GlStateManager._activeTexture(GL13.GL_TEXTURE0) + GlStateManager._bindTexture(glId(texture)) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_R, GL11.GL_BLUE) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_B, GL11.GL_RED) + } + companion object { - val isSupported: Boolean get() = false - fun probe(): Boolean = false + @Volatile + private var supported = true + + /** GlTexture.glId() is public but its declaring class is package-private — invoke needs access. */ + private fun glId(texture: GpuTexture): Int = + texture.javaClass.getMethod("glId").apply { isAccessible = true }.invoke(texture) as Int + + /** Whether external-GL-texture wrapping is available (false once a build attempt fails). */ + val isSupported: Boolean + get() = supported + + private var ctorCache: Constructor<*>? = null + + /** The GlDevice backend's [FrameBufferCache], read from GpuDevice's private `backend` field. */ + private fun frameBufferCache(): FrameBufferCache { + val device = RenderSystem.getDevice() + val backendField = device.javaClass.getDeclaredField("backend") + backendField.isAccessible = true + val backend = backendField.get(device) + // frameBufferCache() is public but its declaring class (GlDevice) is package-private, so + // reflective invoke from here needs setAccessible to pass the access check. + val method = backend.javaClass.getMethod("frameBufferCache").apply { isAccessible = true } + return method.invoke(backend) as FrameBufferCache + } + + private fun glTextureCtor(): Constructor<*> { + ctorCache?.let { return it } + val cls = Class.forName("com.mojang.blaze3d.opengl.GlTexture") + val ctor = cls.getDeclaredConstructor( + Int::class.javaPrimitiveType, + String::class.java, + GpuFormat::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + FrameBufferCache::class.java + ) + ctor.isAccessible = true + ctorCache = ctor + return ctor + } + + /** + * Probe once whether the external-id GlTexture wrapping constructor exists. Called during + * acceleration gating so a blaze3d layout we can't wrap disables zero-copy up front (CPU path). + */ + fun probe(): Boolean { + if (!supported) return false + return try { + glTextureCtor() + true + } catch (t: Throwable) { + logger.warn("Accelerated browser texture wrapping unavailable; GPU zero-copy disabled: {}", t.toString()) + supported = false + false + } + } } } *///?} @@ -68,17 +291,19 @@ class AcceleratedBrowserTexture { } private fun buildSetup(texId: Int, width: Int, height: Int): TextureSetup { - // GlTexture(int glId, String label, TextureFormat format, int width, int height, - // int depthOrLayers, int mipLevels, int usage) — wraps an EXISTING id (no glGen). + // GlTexture(int usage, String label, TextureFormat format, int width, int height, + // int depthOrLayers, int mipLevels, int glId) — usage FIRST, the existing GL id + // LAST (verified against the GlTexture constructor bytecode on 1.21.8/1.21.11: arg 1 goes to + // GpuTexture.usage, arg 8 to GlTexture.id). Wraps the id, no glGen. val glTexture = glTextureCtor().newInstance( - texId, + GpuTexture.USAGE_TEXTURE_BINDING, "FPSMaster browser accel", TextureFormat.RGBA8, width, height, 1, 1, - GpuTexture.USAGE_TEXTURE_BINDING + texId ) as GpuTexture // Reflect GlTextureView directly (rather than device.createTextureView) so nothing on the GL diff --git a/src/main/kotlin/top/fpsmaster/web/cef/BrowserOwnedTexture.kt b/src/main/kotlin/top/fpsmaster/web/cef/BrowserOwnedTexture.kt index 043a4a2..880c259 100644 --- a/src/main/kotlin/top/fpsmaster/web/cef/BrowserOwnedTexture.kt +++ b/src/main/kotlin/top/fpsmaster/web/cef/BrowserOwnedTexture.kt @@ -55,6 +55,10 @@ class BrowserOwnedTexture : AutoCloseable { dst = ByteBuffer.allocateDirect(pixels) scratch = dst } + // Reset limit to capacity: the previous upload shrank it to that frame's size (below), and a + // grow-after-shrink resize would otherwise fail every absolute put past the stale limit + // (IndexOutOfBounds each frame -> permanently black webview after window resizes). + dst.clear() // Absolute get/put so neither buffer's position is disturbed (CEF buffer starts at 0). var i = 0 while (i < pixels) { diff --git a/src/main/kotlin/top/fpsmaster/web/cef/ClientBrowser.kt b/src/main/kotlin/top/fpsmaster/web/cef/ClientBrowser.kt index 9b8536c..fc726f4 100644 --- a/src/main/kotlin/top/fpsmaster/web/cef/ClientBrowser.kt +++ b/src/main/kotlin/top/fpsmaster/web/cef/ClientBrowser.kt @@ -220,13 +220,23 @@ class ClientBrowser( navigationPending = false } - // 26.2 deferred-render draw: record the CPU-uploaded browser frame as a textured quad straight - // into the GUI render state via GuiGraphicsExtractor.blit(view, sampler, …). The old submit-node - // path (IGuiGraphics/GuiRenderState.submitGuiElement/TexQuad) is gone; the accelerated zero-copy - // path is deferred (accelerate is hardcoded false, so useAccelerated is always false here — mcef - // does software rendering and BrowserOwnedTexture already swizzled BGRA→RGBA on upload). + // 26.2 deferred-render draw via GuiGraphicsExtractor.blit(view, sampler, …) (the old submit-node + // path IGuiGraphics/GuiRenderState.submitGuiElement/TexQuad is gone). Accelerated frames arrive as + // a GL texture id from mcef; AcceleratedBrowserTexture copies it into a device texture (with a BGRA + // swizzle) the deferred blit can sample. Otherwise draw the CPU-uploaded owned texture. //? if >=26 { - /*if (ownedTexture.ready) { + /*if (useAccelerated) { + val accelView = acceleratedTexture.setup( + accelTexId, browser.displayedAcceleratedWidth, browser.displayedAcceleratedHeight + ) + if (accelView != null) { + // CEF's GPU shared texture is top-down, same as the software-paint texture — draw it + // with the same UVs as the CPU path. + guiGraphics.delegate.blit(accelView, acceleratedTexture.sampler, 0, 0, width, height, 0f, 1f, 0f, 1f) + return + } + } + if (ownedTexture.ready) { guiGraphics.delegate.blit( ownedTexture.view!!, ownedTexture.sampler, 0, 0, width, height, diff --git a/src/main/resources/assets/fpsmaster/lang/en_us.lang b/src/main/resources/assets/fpsmaster/lang/en_us.lang index 120aee3..4c7ea2b 100644 --- a/src/main/resources/assets/fpsmaster/lang/en_us.lang +++ b/src/main/resources/assets/fpsmaster/lang/en_us.lang @@ -1207,7 +1207,7 @@ 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 +replay.hud.loading=Loading %s%% 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) diff --git a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang index 34e42dc..2c5e614 100644 --- a/src/main/resources/assets/fpsmaster/lang/zh_cn.lang +++ b/src/main/resources/assets/fpsmaster/lang/zh_cn.lang @@ -1210,7 +1210,7 @@ replay.clip.saved=已保存片段:%s replay.clip.failed=片段保存失败,详见日志 replay.clip.busy=上一个片段还在保存 replay.clip.empty=还没有可保存的片段,请稍候 -replay.status.truncated=录像过大,只载入了前 %s 分钟 +replay.hud.loading=加载中 %s%% configbackup.title=配置备份 configbackup.restored=配置文件损坏,已从 %s 的备份恢复 configbackup.reset=配置文件损坏且没有可用备份,已恢复默认设置(原文件保存为 %s) diff --git a/src/test/kotlin/top/fpsmaster/replay/LegacyReplay.kt b/src/test/kotlin/top/fpsmaster/replay/LegacyReplay.kt new file mode 100644 index 0000000..0000930 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/replay/LegacyReplay.kt @@ -0,0 +1,45 @@ +package top.fpsmaster.replay + +import java.io.DataOutputStream +import java.io.File +import java.util.zip.GZIPOutputStream + +/** + * Writes a format 1 recording byte for byte as the original writer did: one gzip stream holding the + * header and every record. Spelled out here rather than calling into [NovaReplayFile], so a change + * to the shared record encoding cannot quietly keep these tests passing. + */ +object LegacyReplay { + fun write(file: File, minecraftVersion: String, startMillis: Long, profile: RecordingProfile, records: List) { + DataOutputStream(GZIPOutputStream(file.outputStream(), true)).use { out -> + out.writeUTF("NOVAREPL") + out.writeInt(1) + out.writeUTF(minecraftVersion) + out.writeLong(startMillis) + profile.write(out) + for (record in records) { + out.writeByte(record.type) + out.writeInt(record.millis) + when (record.type) { + 1 -> { + out.writeDouble(record.x) + out.writeDouble(record.y) + out.writeDouble(record.z) + out.writeFloat(record.yaw) + out.writeFloat(record.pitch) + out.writeByte(record.flags) + } + 2 -> { + out.writeByte(record.slot) + out.writeInt(record.payload!!.size) + out.write(record.payload!!) + } + else -> { + out.writeInt(record.payload!!.size) + out.write(record.payload!!) + } + } + } + } + } +} diff --git a/src/test/kotlin/top/fpsmaster/replay/NovaReplayFileTest.kt b/src/test/kotlin/top/fpsmaster/replay/NovaReplayFileTest.kt index 0f15c87..3142ad1 100644 --- a/src/test/kotlin/top/fpsmaster/replay/NovaReplayFileTest.kt +++ b/src/test/kotlin/top/fpsmaster/replay/NovaReplayFileTest.kt @@ -105,7 +105,11 @@ class NovaReplayFileTest { fun `a truncated recording reads up to the cut`() { val file = temporary() NovaReplayFile.openForWrite(file, "1.21.11", 0L, profile).use { writer -> - repeat(8) { writer.write(ReplayRecord.packet(it * 50, ByteArray(64) { 7 })) } + repeat(8) { + writer.write(ReplayRecord.packet(it * 50, ByteArray(64) { 7 })) + // A crash costs what came after the last flush; flushes close blocks. + if (it == 3) writer.flush() + } } val whole = file.readBytes() file.writeBytes(whole.copyOf(whole.size - 40)) @@ -131,4 +135,95 @@ class NovaReplayFileTest { assertEquals(2, readBack.size) writer.close() } + + @Test + fun `a corrupted block ends the recording at the block before it`() { + val file = temporary() + NovaReplayFile.openForWrite(file, "1.21.11", 0L, profile).use { writer -> + repeat(3) { writer.write(ReplayRecord.packet(it, byteArrayOf(1, 2, 3))) } + writer.flush() + repeat(3) { writer.write(ReplayRecord.packet(10 + it, byteArrayOf(4, 5, 6))) } + } + val bytes = file.readBytes() + bytes[bytes.size - 3] = (bytes[bytes.size - 3] + 1).toByte() + file.writeBytes(bytes) + + val records = NovaReplayFile.openForRead(file, "1.21.11").use { reader -> + generateSequence { reader.read() }.toList() + } + assertEquals(listOf(0, 1, 2), records.map { it.millis }) + } + + @Test + fun `the index reads the length without inflating and agrees with a full read`() { + val file = temporary() + NovaReplayFile.openForWrite(file, "1.21.11", 0L, profile).use { writer -> + for (i in 0 until 5_000) { + writer.write(ReplayRecord.packet(i * 10, ByteArray(200) { (i + it).toByte() })) + if (i % 700 == 0) writer.flush() + } + } + + val index = NovaReplayFile.scanIndex(file) + assertEquals(49_990, index.durationMillis) + assertEquals(5_000L, index.recordCount) + assertTrue(index.blockCount > 1, "a recording this size spans several blocks") + } + + // ------------------------------------------------------------------ format 1 compatibility + + @Test + fun `a format 1 recording still reads back record for record`() { + val file = temporary() + LegacyReplay.write(file, "1.21.11", 99L, profile, listOf( + ReplayRecord.snapshotPacket(0, byteArrayOf(1, 2, 3)), + ReplayRecord.packet(120, byteArrayOf(9, 8, 7, 6)), + ReplayRecord.localPlayer(240, 1.5, 64.0, -2.5, 90f, -12f, 5), + ReplayRecord.equipment(360, 2, byteArrayOf(4, 4)), + )) + + assertEquals(99L, NovaReplayFile.readHeader(file).startMillis) + val records = NovaReplayFile.openForRead(file, "1.21.11").use { reader -> + assertEquals(profile, reader.header.profile) + generateSequence { reader.read() }.toList() + } + assertEquals(listOf(0, 120, 240, 360), records.map { it.millis }) + assertEquals( + listOf( + NovaReplayFile.TYPE_SNAPSHOT_PACKET, + NovaReplayFile.TYPE_PACKET, + NovaReplayFile.TYPE_LOCAL_PLAYER, + NovaReplayFile.TYPE_LOCAL_EQUIPMENT, + ), + records.map { it.type }, + ) + assertContentEquals(byteArrayOf(9, 8, 7, 6), records[1].payload) + assertEquals(-12f, records[2].pitch) + assertEquals(2, records[3].slot) + + val index = NovaReplayFile.scanIndex(file) + assertEquals(360, index.durationMillis) + assertEquals(4L, index.recordCount) + } + + @Test + fun `a format 1 recording from another version is still refused by name`() { + val file = temporary() + LegacyReplay.write(file, "1.20.1", 0L, profile, listOf(ReplayRecord.packet(0, byteArrayOf(1)))) + + assertFailsWith { NovaReplayFile.openForRead(file, "1.21.11") } + } + + @Test + fun `new recordings are written as format 2`() { + val file = temporary() + NovaReplayFile.openForWrite(file, "1.21.11", 0L, profile).close() + + val input = java.io.DataInputStream(file.inputStream()) + input.use { + assertEquals(NovaReplayFile.MAGIC, it.readUTF()) + assertEquals(NovaReplayFile.FORMAT_VERSION, it.readInt()) + } + assertEquals(2, NovaReplayFile.FORMAT_VERSION) + } } diff --git a/src/test/kotlin/top/fpsmaster/replay/ReplayStreamingTest.kt b/src/test/kotlin/top/fpsmaster/replay/ReplayStreamingTest.kt new file mode 100644 index 0000000..38ae656 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/replay/ReplayStreamingTest.kt @@ -0,0 +1,227 @@ +package top.fpsmaster.replay + +import java.io.File +import java.nio.ByteBuffer +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class ReplayStreamingTest { + + private val profile = RecordingProfile("Nova", UUID(3L, 4L), "minecraft:overworld", "local", 20, true) + private val version = "1.21.11" + private val opened = ArrayList() + + @AfterTest + fun closeAll() = opened.forEach { it.close() } + + private fun temporary(): File = File.createTempFile("nova-stream", ".novareplay").also { it.deleteOnExit() } + + /** A payload that says which record it is, so the order things were applied in can be checked. */ + private fun tagged(ordinal: Int, size: Int = 8): ByteArray = + ByteBuffer.allocate(maxOf(size, 4)).putInt(ordinal).array() + + private fun ordinalOf(record: ReplayRecord): Int = ByteBuffer.wrap(record.payload!!).int + + /** + * Five terrain snapshot records at zero, then one packet every 10 ms up to [last], flushed every + * so often so the file has many blocks. Returns every record in file order. + */ + private fun synthetic(file: File, last: Int = 9_990): List { + val records = ArrayList() + repeat(5) { records.add(ReplayRecord.snapshotPacket(0, tagged(records.size))) } + for (millis in 0..last step 10) { + records.add(ReplayRecord.packet(millis, tagged(records.size))) + } + NovaReplayFile.openForWrite(file, version, 0L, profile).use { writer -> + records.forEachIndexed { i, record -> + writer.write(record) + if (i % 97 == 0) writer.flush() + } + } + return records + } + + private class World { + val applied = ArrayList() + var rebuilds = 0 + } + + private fun cursor(file: File, world: World, window: Long = 1L shl 20): ReplayCursor = + ReplayCursor( + openStream = { ReplayStream({ NovaReplayFile.openForRead(file, version) }, window).also { opened.add(it) } }, + apply = { world.applied.add(it) }, + rebuild = { + world.applied.clear() + world.rebuilds++ + }, + ).also { opened.add(it) } + + /** Pumps without a time limit until caught up, giving the reader thread time to fill the window. */ + private fun settle(cursor: ReplayCursor) { + val deadline = System.currentTimeMillis() + 20_000 + while (!cursor.pump(Long.MAX_VALUE)) { + if (System.currentTimeMillis() > deadline) fail("never caught up to ${cursor.targetMillis}") + Thread.sleep(1) + } + } + + private fun expectedUpTo(records: List, millis: Int): List = + records.filter { it.millis <= millis }.map { ordinalOf(it) } + + @Test + fun `seeking forward applies exactly what lies between`() { + val file = temporary() + val records = synthetic(file) + val world = World() + val cursor = cursor(file, world) + + settle(cursor) + assertEquals(expectedUpTo(records, 0), world.applied.map { ordinalOf(it) }, "time zero is the snapshot and first packet") + + cursor.seekTo(5_000) + settle(cursor) + assertEquals(expectedUpTo(records, 5_000), world.applied.map { ordinalOf(it) }) + assertEquals(0, world.rebuilds, "forwards never rebuilds") + } + + @Test + fun `seeking backwards rebuilds and lands on the same state as playing there directly`() { + val file = temporary() + val records = synthetic(file) + val world = World() + val cursor = cursor(file, world) + + cursor.seekTo(7_000) + settle(cursor) + cursor.seekTo(2_505) + settle(cursor) + + assertEquals(1, world.rebuilds) + assertEquals(expectedUpTo(records, 2_505), world.applied.map { ordinalOf(it) }) + assertEquals(2_505, cursor.targetMillis) + + // Already there: no second rebuild, nothing applied twice. + cursor.seekTo(2_505) + settle(cursor) + assertEquals(1, world.rebuilds) + assertEquals(expectedUpTo(records, 2_505), world.applied.map { ordinalOf(it) }) + } + + @Test + fun `a seek part way through loading that goes backwards still rebuilds`() { + val file = temporary() + val records = synthetic(file) + val world = World() + val cursor = cursor(file, world) + + cursor.seekTo(9_000) + Thread.sleep(200) // let the reader buffer ahead; the window holds this whole file + // One record per pump: the deadline is always already past. + repeat(400) { cursor.pump(0L) { 1L } } + assertFalse(cursor.isCaughtUp) + val reached = cursor.appliedMillis + assertTrue(reached in 1..8_999, "loading was interrupted part way, at $reached") + + cursor.seekTo(reached - 500) + settle(cursor) + assertEquals(1, world.rebuilds, "the world was already past the new target") + assertEquals(expectedUpTo(records, reached - 500), world.applied.map { ordinalOf(it) }) + } + + @Test + fun `running off the end reports the end`() { + val file = temporary() + val records = synthetic(file, last = 990) + val world = World() + val cursor = cursor(file, world) + + cursor.seekTo(60_000) + settle(cursor) + assertTrue(cursor.isAtEnd) + assertEquals(records.size, world.applied.size) + } + + @Test + fun `a budget of nothing still makes progress one record at a time`() { + val file = temporary() + synthetic(file) + val world = World() + val cursor = cursor(file, world) + cursor.seekTo(1_000) + + val deadline = System.currentTimeMillis() + 10_000 + while (world.applied.isEmpty() && System.currentTimeMillis() < deadline) { + cursor.pump(0L) { 1L } + Thread.sleep(1) + } + assertEquals(1, world.applied.size, "exactly one record per exhausted budget") + } + + @Test + fun `memory holds a bounded window however long the recording is`() { + val file = temporary() + val payload = 64 shl 10 + val count = 3_000 // ~190 MB of records, compressing to a few MB on disk + NovaReplayFile.openForWrite(file, version, 0L, profile).use { writer -> + for (i in 0 until count) { + writer.write(ReplayRecord.packet(i * 50, tagged(i, payload))) + if (i % 40 == 0) writer.flush() + } + } + val window = 2L shl 20 + val world = World() + var appliedBytes = 0L + var lastOrdinal = -1 + var stream: ReplayStream? = null + val cursor = ReplayCursor( + openStream = { + ReplayStream({ NovaReplayFile.openForRead(file, version) }, window).also { + stream = it + opened.add(it) + } + }, + apply = { + // Dropped as soon as it is applied, the way playback does: only the window is held. + appliedBytes += it.payload!!.size + val ordinal = ordinalOf(it) + assertEquals(lastOrdinal + 1, ordinal, "records arrive in order") + lastOrdinal = ordinal + }, + rebuild = { world.rebuilds++ }, + ).also { opened.add(it) } + + // A slow consumer first, so the reader fills the window and has to wait. + cursor.seekTo(count * 50) + Thread.sleep(300) + settle(cursor) + + assertEquals(count - 1, lastOrdinal) + assertTrue(appliedBytes > window * 50, "the recording ($appliedBytes bytes) dwarfs the window") + val peak = stream!!.peakBufferedBytes + assertTrue(peak <= window, "read-ahead peaked at $peak bytes, over the $window byte window") + assertTrue(peak > window / 2, "the reader did read ahead into the window ($peak bytes)") + } + + @Test + fun `a format 1 recording streams and seeks like a new one`() { + val file = temporary() + val records = (0 until 300).map { ReplayRecord.packet(it * 20, tagged(it)) } + LegacyReplay.write(file, version, 0L, profile, records) + val world = World() + val cursor = cursor(file, world) + + cursor.seekTo(4_000) + settle(cursor) + cursor.seekTo(1_000) + settle(cursor) + + assertEquals(1, world.rebuilds) + assertEquals(expectedUpTo(records, 1_000), world.applied.map { ordinalOf(it) }) + assertEquals(5_980, NovaReplayFile.scanIndex(file).durationMillis) + } +} diff --git a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1-sources.jar b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2-sources.jar similarity index 55% rename from vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1-sources.jar rename to vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2-sources.jar index 2a9c24e..86b6a26 100644 Binary files a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1-sources.jar and b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2-sources.jar differ diff --git a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.jar b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.jar similarity index 78% rename from vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.jar rename to vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.jar index 5c9873c..de2bcfc 100644 Binary files a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.jar and b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.jar differ diff --git a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.module b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.module similarity index 53% rename from vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.module rename to vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.module index 08164b9..6dd7c3f 100644 --- a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.module +++ b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.module @@ -3,7 +3,7 @@ "component": { "group": "com.github.FPSMasterTeam", "module": "mcef-nova", - "version": "1.0.1", + "version": "1.0.2", "attributes": { "org.gradle.status": "release" } @@ -34,13 +34,13 @@ ], "files": [ { - "name": "mcef-nova-1.0.1.jar", - "url": "mcef-nova-1.0.1.jar", - "size": 261505, - "sha512": "fcbf453627231279365c478093efbf3b2fc7307853aa2087d43684c091154220a5c738df52aa5d8cef2c2ffbfb41a3ad955092dddc1545d5b416653f8a2e8e7a", - "sha256": "4eb44381d7cf47502d79bfeb5b5c10cc808d410139f4c73b3623d2ae7ea2b50c", - "sha1": "49fb472e86b3d8cc0d7e6834721ce71455e0cdd9", - "md5": "6cfcbd8836a5c81ac248fcfd012c381b" + "name": "mcef-nova-1.0.2.jar", + "url": "mcef-nova-1.0.2.jar", + "size": 258277, + "sha512": "a7f4e077df982540b8a0b6c419c3b575381f57031d3b91bba53ebf2cda77c36ef1fdff05ca4c7da7f9776100daac73f8af7691f7aeeac890286176fe721eb21f", + "sha256": "c710a11e53671275ca57bd7dc96410569afbabd0c2361df7d351e040d0e5aae6", + "sha1": "740cd54ff8bb608f930d2139f5fa9f011bf72c4a", + "md5": "7f080c33dde2ab97fb1f5a46abcc55cf" } ] }, @@ -64,13 +64,13 @@ ], "files": [ { - "name": "mcef-nova-1.0.1.jar", - "url": "mcef-nova-1.0.1.jar", - "size": 261505, - "sha512": "fcbf453627231279365c478093efbf3b2fc7307853aa2087d43684c091154220a5c738df52aa5d8cef2c2ffbfb41a3ad955092dddc1545d5b416653f8a2e8e7a", - "sha256": "4eb44381d7cf47502d79bfeb5b5c10cc808d410139f4c73b3623d2ae7ea2b50c", - "sha1": "49fb472e86b3d8cc0d7e6834721ce71455e0cdd9", - "md5": "6cfcbd8836a5c81ac248fcfd012c381b" + "name": "mcef-nova-1.0.2.jar", + "url": "mcef-nova-1.0.2.jar", + "size": 258277, + "sha512": "a7f4e077df982540b8a0b6c419c3b575381f57031d3b91bba53ebf2cda77c36ef1fdff05ca4c7da7f9776100daac73f8af7691f7aeeac890286176fe721eb21f", + "sha256": "c710a11e53671275ca57bd7dc96410569afbabd0c2361df7d351e040d0e5aae6", + "sha1": "740cd54ff8bb608f930d2139f5fa9f011bf72c4a", + "md5": "7f080c33dde2ab97fb1f5a46abcc55cf" } ] }, @@ -84,13 +84,13 @@ }, "files": [ { - "name": "mcef-nova-1.0.1-sources.jar", - "url": "mcef-nova-1.0.1-sources.jar", - "size": 53642, - "sha512": "c23a146f99e1bae1148e9b2b4e8910921c1d90cc164ba29f3bcbd0321a35f288d62a83b8e9093a4d1c4c25bea4fb3327ed52a5f232f695b5f9cf0a22506cd0e9", - "sha256": "1f8f65faab67d11eebcbd875546080197890a16c67c52b3d05629f0726ca8283", - "sha1": "5ee40d75652b0e68547f8a2dbb3dc72791eb0235", - "md5": "a7addc0307bb9d4b59f8163fc7b20e18" + "name": "mcef-nova-1.0.2-sources.jar", + "url": "mcef-nova-1.0.2-sources.jar", + "size": 50763, + "sha512": "306992d4defaf183d14f81cfce46cda95ea7601d47da97c42afac2a94f8b1670dd9cbc73fc4aef3c065bfe740d8cdf94ef95088a96efd2427f221ac1ae39b8ed", + "sha256": "a71556c183aa449987a2b7a5cd8630be5d9f8e0ec57f2dab070277f6cf0eda16", + "sha1": "510a3d84cb641ad6be68c93734b13e49f4d139cb", + "md5": "6f36e0ff31b227238e2e2ece6f4e86f1" } ] } diff --git a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.pom b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.pom similarity index 97% rename from vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.pom rename to vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.pom index 557b83b..a85c3d6 100644 --- a/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.1/mcef-nova-1.0.1.pom +++ b/vendor/maven/com/github/FPSMasterTeam/mcef-nova/1.0.2/mcef-nova-1.0.2.pom @@ -9,7 +9,7 @@ 4.0.0 com.github.FPSMasterTeam mcef-nova - 1.0.1 + 1.0.2 com.squareup.okhttp3