From f2bbdb370e6a6de160612fc4dfcfb8923d3b8c25 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 17:27:09 +0100 Subject: [PATCH 01/42] Add bounded protocol 34 demo record reader and body decoder --- .../jake2/qcommon/network/demo/DemoReader.kt | 62 +++++++++++++++++++ .../network/protocol/MessageBodyCodec.kt | 12 +++- .../qcommon/network/demo/DemoReaderTest.kt | 57 +++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoReaderTest.kt diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt new file mode 100644 index 00000000..5c5f89ad --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt @@ -0,0 +1,62 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.transport.PacketBytes +import java.io.Closeable +import java.io.IOException +import java.io.InputStream + +/** Owns a protocol-34 .dm2 stream: little-endian lengths followed by server message bodies. */ +class DemoReader(private val input: InputStream) : Closeable { + sealed interface Record { + data class Body(val bytes: PacketBytes) : Record + data object End : Record + } + + private var ended = false + private var closed = false + var offset: Long = 0 + private set + + /** Reads exactly one record, including empty records. EOF without the -1 marker is incomplete. */ + fun read(): Record { + check(!closed) { "Demo reader is closed" } + if (ended) return Record.End + val header = readExactly(4) + val length = (header[0].toInt() and 255) or ((header[1].toInt() and 255) shl 8) or + ((header[2].toInt() and 255) shl 16) or (header[3].toInt() shl 24) + if (length == -1) { + ended = true + return Record.End + } + if (length !in 0..Defines.MAX_MSGLEN) throw IOException("Invalid demo record length $length at byte ${offset - 4}") + return Record.Body(PacketBytes.copyOf(readExactly(length))) + } + + private fun readExactly(length: Int): ByteArray { + val bytes = ByteArray(length) + var read = 0 + while (read < length) { + // Single-byte reads also work with streams that return zero from bulk reads. + val count = input.read(bytes, read, length - read) + if (count < 0) throw IOException("Incomplete demo at byte $offset (missing record data or terminator)") + if (count == 0) { + val value = input.read() + if (value < 0) throw IOException("Incomplete demo at byte $offset") + bytes[read++] = value.toByte() + offset++ + } else { + read += count + offset += count + } + } + return bytes + } + + override fun close() { + if (!closed) { + closed = true + input.close() + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt index b8c14b33..efeef6fd 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt @@ -1,5 +1,6 @@ package jake2.qcommon.network.protocol +import jake2.qcommon.network.transport.PacketBytes import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.client.* import jake2.qcommon.network.messages.server.* @@ -12,6 +13,10 @@ object MessageBodyCodec { @JvmStatic fun decodeServer(envelope: PacketEnvelope): MessageDecodeResult = decode(envelope, PacketDirection.FROM_SERVER, ServerMessageCodec::read) { it is EndOfServerPacketMessage } + /** Decode a complete server body, including demo records which have no channel header. */ + @JvmStatic fun decodeServerBody(bytes: PacketBytes): MessageDecodeResult = + decodeReader(MessageReader(bytes, 0), ServerMessageCodec::read) { it is EndOfServerPacketMessage } + @JvmStatic fun decodeClient(envelope: PacketEnvelope, sequence: Int): MessageDecodeResult = decode(envelope, PacketDirection.FROM_CLIENT, { ClientMessageCodec.read(it, sequence) }) { it is EndOfClientPacketMessage } @@ -23,7 +28,12 @@ object MessageBodyCodec { return MessageDecodeResult.Rejected(MessageRejection.NOT_SEQUENCED, 0, "Connectionless packet has no message body") if ((envelope.qport != null) != (direction == PacketDirection.FROM_CLIENT)) return MessageDecodeResult.Rejected(MessageRejection.WRONG_DIRECTION, 0, "Unexpected channel header") - val reader = MessageReader(envelope.bytes, envelope.headerSize) + return decodeReader(MessageReader(envelope.bytes, envelope.headerSize), parse, isEnd) + } + + private fun decodeReader( + reader: MessageReader, parse: (MessageReader) -> T?, isEnd: (T) -> Boolean, + ): MessageDecodeResult { val messages = ArrayList() return try { while (reader.readcount < reader.cursize) { diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoReaderTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoReaderTest.kt new file mode 100644 index 00000000..290abaaa --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoReaderTest.kt @@ -0,0 +1,57 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.messages.server.PrintMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoReaderTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + + @Test fun `independently framed protocol 34 print record needs no channel header`() { + // Hand-authored from the .dm2 layout in Yamagi CL_WriteDemoMessage and svc_print (opcode 10). + val body = byteArrayOf(10, 2, 'h'.code.toByte(), 'i'.code.toByte(), 0) + DemoReader(ByteArrayInputStream(int(0) + int(body.size) + body + int(-1))).use { reader -> + assertEquals(0, (reader.read() as DemoReader.Record.Body).bytes.size) + val result = MessageBodyCodec.decodeServerBody((reader.read() as DemoReader.Record.Body).bytes) + as MessageDecodeResult.Decoded + assertEquals("hi", (result.messages.single() as PrintMessage).text) + assertEquals(DemoReader.Record.End, reader.read()) + assertEquals(DemoReader.Record.End, reader.read()) + } + } + + @Test fun `all truncated prefixes and invalid lengths fail before allocation`() { + val valid = int(2) + byteArrayOf(6, 6) + int(-1) + for (size in 0 until valid.size) { + DemoReader(ByteArrayInputStream(valid.copyOf(size))).use { reader -> + assertThrows(IOException::class.java) { while (reader.read() != DemoReader.Record.End) Unit } + } + } + for (length in listOf(-2, Int.MIN_VALUE, Defines.MAX_MSGLEN + 1, Int.MAX_VALUE)) { + DemoReader(ByteArrayInputStream(int(length))).use { reader -> + assertThrows(IOException::class.java) { reader.read() } + } + } + DemoReader(ByteArrayInputStream(int(Defines.MAX_MSGLEN) + ByteArray(Defines.MAX_MSGLEN))).use { + assertEquals(Defines.MAX_MSGLEN, (it.read() as DemoReader.Record.Body).bytes.size) + } + } + + @Test fun `reader owns and closes input once`() { + var closes = 0 + val reader = DemoReader(object : ByteArrayInputStream(int(-1)) { + override fun close() { closes++ } + }) + reader.close() + reader.close() + assertEquals(1, closes) + assertThrows(IllegalStateException::class.java) { reader.read() } + } +} From 888cc74f7bdd0ddef25be11d7dea03d386ccc5c6 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 17:27:59 +0100 Subject: [PATCH 02/42] Add client demo playback clock with bounded record dispatch --- .../org/demoth/cake/demo/DemoPlayback.kt | 112 ++++++++++++++++++ .../org/demoth/cake/demo/DemoPlaybackTest.kt | 79 ++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt new file mode 100644 index 00000000..54d6e41d --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt @@ -0,0 +1,112 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.FrameHeaderMessage +import jake2.qcommon.network.messages.server.ServerDataMessage +import jake2.qcommon.network.messages.server.ServerMessage +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import java.io.Closeable +import java.io.IOException + +/** Client-owned playback clock. Neither sockets nor a running game server participate. */ +class DemoPlayback(private val reader: DemoReader) : Closeable { + private data class Pending(val messages: List?, val due: Double, val frameTime: Int?) + private var pending: Pending? = null + private var closed = false + private var serverDataSeen = false + private var originFrame: Int? = null + private var previousFrame: Int? = null + private var originDue = 0.0 + private var lastDue = 0.0 + private var elapsed = 0.0 + private var framePresented = false + private var presentedDue = 0.0 + private var presentedTime = 0 + + /** Absolute recorded render time, one snapshot behind the current frame for interpolation. */ + val presentationTimeMillis: Int + get() = presentedTime - 100 + ((elapsed - presentedDue) * 1000).toInt().coerceIn(0, 100) + val interpolationSeconds: Float + get() = (elapsed - presentedDue).toFloat().coerceIn(0f, .1f) + + /** + * Dispatch whole validated records in order. Returns false on normal completion or explicit close. + * The record budget bounds bootstrap/empty-record work and catch-up after long application stalls. + * Returning false from [dispatch] stops immediately, for example after a recorded disconnect. + */ + fun update(deltaSeconds: Float, dispatch: (List) -> Boolean): Boolean { + if (closed) return false + require(deltaSeconds.isFinite() && deltaSeconds >= 0f) + if (framePresented) elapsed += deltaSeconds + repeat(MAX_RECORDS_PER_UPDATE) { + val next = pending ?: readPending().also { pending = it } + if (next.due > elapsed + 0.000001) return true + pending = null + val messages = next.messages + if (messages == null) { + close() + return false + } + if (!dispatch(messages) || closed) return false + if (next.frameTime != null) { + framePresented = true + presentedTime = next.frameTime + presentedDue = next.due + } + } + return true + } + + private fun readPending(): Pending { + val record = reader.read() + if (record == DemoReader.Record.End) { + if (!serverDataSeen || !framePresented) throw IOException("Demo contains no playable world frames") + return Pending(null, lastDue + .1, null) + } + val body = (record as DemoReader.Record.Body).bytes + val messages = when (val result = MessageBodyCodec.decodeServerBody(body)) { + is MessageDecodeResult.Decoded -> result.messages + is MessageDecodeResult.Rejected -> throw IOException("Invalid demo message at byte ${reader.offset - body.size + result.offset}: ${result.detail}") + } + if (messages.any { it is ServerDataMessage }) { + for (data in messages.filterIsInstance()) { + if (data.protocol != Defines.PROTOCOL_VERSION) + throw IOException("Unsupported demo protocol ${data.protocol}; expected ${Defines.PROTOCOL_VERSION}") + if (data.playerNumber < 0) throw IOException("Cinematic demos are not supported") + } + serverDataSeen = true + originFrame = null + previousFrame = null + originDue = if (framePresented) lastDue + .1 else 0.0 + lastDue = originDue + } + if (!serverDataSeen && messages.isNotEmpty()) throw IOException("Demo is missing initial server data") + val frames = messages.filterIsInstance() + if (frames.size > 1) throw IOException("Multiple snapshots in one demo record") + val frame = frames.singleOrNull() + if (frame != null) { + if (frame.frameNumber < 0 || frame.frameNumber > Int.MAX_VALUE / 100) + throw IOException("Invalid demo frame number ${frame.frameNumber}") + if (previousFrame != null && frame.frameNumber <= previousFrame!!) + throw IOException("Demo frame numbers do not advance") + if (originFrame == null) originFrame = frame.frameNumber + lastDue = originDue + (frame.frameNumber.toLong() - originFrame!!) / 10.0 + previousFrame = frame.frameNumber + } + return Pending(messages, lastDue, frame?.frameNumber?.times(100)) + } + + override fun close() { + if (!closed) { + closed = true + pending = null + reader.close() + } + } + + companion object { + const val MAX_RECORDS_PER_UPDATE = 64 + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt new file mode 100644 index 00000000..4f93db72 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.demo + +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoPlaybackTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + private fun record(vararg messages: ServerMessage): ByteArray { + val buffer = sizebuf_t(1400) + messages.forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + } + private fun bootstrap(protocol: Int = 34) = record(ServerDataMessage(protocol, 1, true, "baseq2", 0, "test"), StuffTextMessage("precache\n")) + private fun frame(number: Int) = record(FrameHeaderMessage(number, -1, 0, 0, byteArrayOf())) + private fun playback(bytes: ByteArray) = DemoPlayback(DemoReader(ByteArrayInputStream(bytes))) + + @Test fun `bootstrap is immediate then frames follow recorded time and last frame remains visible`() { + playback(bootstrap() + frame(100) + frame(101) + frame(104) + int(-1)).use { demo -> + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { messages -> + frames += messages.filterIsInstance().map { it.frameNumber }; true + } + assertTrue(demo.update(0f, dispatch)) + assertEquals(listOf(100), frames) + assertEquals(9900, demo.presentationTimeMillis) + assertTrue(demo.update(.05f, dispatch)) + assertEquals(9950, demo.presentationTimeMillis) + assertTrue(demo.update(.05f, dispatch)) + assertEquals(listOf(100, 101), frames) + assertEquals(10000, demo.presentationTimeMillis) + assertTrue(demo.update(.3f, dispatch)) + assertEquals(listOf(100, 101, 104), frames) + assertFalse(demo.update(.1f, dispatch)) + } + } + + @Test fun `empty records cannot monopolize a client update`() { + playback(ByteArray(4 * 1000) + bootstrap() + frame(1) + int(-1)).use { demo -> + var dispatched = 0 + assertTrue(demo.update(0f) { dispatched++; true }) + assertEquals(DemoPlayback.MAX_RECORDS_PER_UPDATE, dispatched) + } + } + + @Test fun `unsupported protocol and malformed body dispatch no prefix`() { + for (bytes in listOf(bootstrap(26), record(ServerDataMessage(34, 1, true, "baseq2", 0, "test")) + int(2) + byteArrayOf(6, 7))) { + playback(bytes).use { demo -> + assertThrows(IOException::class.java) { demo.update(0f) { true } } + } + } + } + + @Test fun `map bootstrap rebases frame numbers without a new connection`() { + playback(bootstrap() + frame(1000) + bootstrap() + frame(5) + frame(6) + int(-1)).use { demo -> + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { frames += it.filterIsInstance().map { f -> f.frameNumber }; true } + demo.update(0f, dispatch) + assertEquals(listOf(1000), frames) + demo.update(.1f, dispatch) + assertEquals(listOf(1000, 5), frames) + demo.update(.1f, dispatch) + assertEquals(listOf(1000, 5, 6), frames) + } + } + + @Test fun `closing during dispatch stops all further records`() { + val demo = playback(bootstrap() + frame(1) + frame(2) + int(-1)) + var dispatches = 0 + assertFalse(demo.update(0f) { dispatches++; demo.close(); false }) + assertEquals(1, dispatches) + } +} From 1af6d331579a16c63faf05db73631de6e125fe92 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 17:35:14 +0100 Subject: [PATCH 03/42] Play protocol 34 demos from the Cake client console --- cake/cake-fullgame/build.gradle.kts | 3 +- .../jake2/server/CakeDemoPlaybackGlTest.kt | 189 ++++++++++++++++++ .../src/main/kotlin/org/demoth/cake/Cake.kt | 133 +++++++++--- .../org/demoth/cake/ServerStuffTextPolicy.kt | 8 +- .../demoth/cake/assets/CakeFileResolver.kt | 3 + .../demoth/cake/assets/CakeVfsAssetSource.kt | 11 + .../kotlin/org/demoth/cake/demo/DemoPath.kt | 10 + .../demoth/cake/stages/ingame/Game3dScreen.kt | 12 +- .../ingame/WorldPresentationRenderer.kt | 5 +- .../org/demoth/cake/CakeCommandOrderTest.kt | 2 +- .../demoth/cake/ServerStuffTextPolicyTest.kt | 8 + .../cake/assets/CakeFileResolverTest.kt | 13 ++ .../org/demoth/cake/demo/DemoPathTest.kt | 15 ++ 13 files changed, 377 insertions(+), 35 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index e4abf52f..9e0a0bd2 100644 --- a/cake/cake-fullgame/build.gradle.kts +++ b/cake/cake-fullgame/build.gradle.kts @@ -23,9 +23,10 @@ dependencies { tasks.test { // Opt-in flags and content location affect which tests run and what they load. // Changing them must invalidate results that may otherwise contain only skipped tests. - listOf("CAKE_HOST_GAME_TESTS", "CAKE_HOST_GL_TESTS", "CAKE_TEST_BASEDIR").forEach { name -> + listOf("CAKE_HOST_GAME_TESTS", "CAKE_HOST_GL_TESTS", "CAKE_TEST_BASEDIR", "CAKE_TEST_DEMO").forEach { name -> inputs.property(name, providers.environmentVariable(name).orElse("")) } + providers.environmentVariable("CAKE_TEST_DEMO").orNull?.let { inputs.file(it) } if (providers.environmentVariable("CAKE_HOST_GAME_TESTS").orNull == "1") { forkEvery = 1 } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt new file mode 100644 index 00000000..e04453b5 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt @@ -0,0 +1,189 @@ +package jake2.server + +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cmd +import jake2.qcommon.network.ConnectionChannel +import org.demoth.cake.Cake +import org.demoth.cake.CakeStartupBootstrap +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.* +import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.profile.CakeGameProfileStore +import org.demoth.cake.stages.ingame.Game3dScreen +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path + +/** External protocol-34 recording and installed assets remain outside git. Every application window is hidden. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +abstract class CakeDemoPlaybackGlTest { + protected abstract val embedded: Boolean + @TempDir lateinit var temp: Path + + @Test + @EnabledIfEnvironmentVariable(named = "CAKE_TEST_DEMO", matches = ".+") + fun `console playback works with remote-only and embedded host without simulation`() { + val basedir = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) + val demo = Path.of(requireNotNull(System.getenv("CAKE_TEST_DEMO"))) + val content = temp.resolve("content/baseq2") + Files.createDirectories(content.resolve("demos")) + val source = basedir.resolve("baseq2") + Files.walk(source).use { entries -> + entries.filter { !source.relativize(it).startsWith("demos") }.forEach { + val target = content.resolve(source.relativize(it)) + if (Files.isDirectory(it)) Files.createDirectories(target) + else Files.createSymbolicLink(target, it) + } + } + val bytes = Files.readAllBytes(demo) + Files.write(content.resolve("demos/check.dm2"), bytes) + Files.write(content.resolve("demos/truncated.dm2"), bytes.copyOf(bytes.size - 5)) + val unsupported = bytes.copyOf().apply { this[5] = 26 } // first record begins with svc_serverdata + protocol. + Files.write(content.resolve("demos/unsupported.dm2"), unsupported) + val previousHome = System.getProperty("user.home") + System.setProperty("user.home", temp.toString()) + var failure: Throwable? = null + val delegate: CakeHost = if (embedded) EmbeddedCakeHost(temp.resolve(".cake")) else UnavailableCakeHost() + var hostUpdates = 0 + val host = object : CakeHost by delegate { + override fun update(elapsedMillis: Long) { hostUpdates++; delegate.update(elapsedMillis) } + } + try { + CakeStartupBootstrap.ensureInitialized() + CakeGameProfileStore().upsertProfile(CakeGameProfile("demo-test", content.parent.toString())) + val startup = CakeStartupBootstrap.bootstrap() + Lwjgl3Application(object : ApplicationAdapter() { + private lateinit var cake: Cake + private var stage = 0 + private var ticks = 0 + private var initialFrame = 0 + private var frameAdvanced = false + private var deadline = System.nanoTime() + 30_000_000_000L + private var updatesAtOpen = 0 + private var snapshotAtPause = 0 + + override fun create() { + try { + cake = Cake(startup, host = host) + cake.create() + updatesAtOpen = hostUpdates + Cmd.ExecuteString("demomap check") + } catch (error: Throwable) { fail(error) } + } + + override fun render() { + if (failure != null) return + try { + cake.render() + ticks++ + assertTrue(System.nanoTime() < deadline, "Playback timeout at stage $stage") + if (field(cake, "demoPlayback") != null) { + assertEquals(updatesAtOpen, hostUpdates, "Playback must not tick a Host") + assertEquals(HostState.STOPPED, host.state) + assertEquals(1, (field(cake, "netchan") as ConnectionChannel).outgoingSequence) + (delegate as? EmbeddedCakeHost)?.let { + assertTrue(it.server!!.games.isEmpty()) + assertNull(it.udpEndpoint(Defines.NS_CLIENT)) + assertNull(it.udpEndpoint(Defines.NS_SERVER)) + } + } + val screen = field(cake, "game3dScreen") as? Game3dScreen + val entities = screen?.let { field(it, "entityManager") as ClientEntityManager } + val frame = entities?.currentFrame + when (stage) { + 0 -> if (frame?.valid == true && state() == "ACTIVE") { + assertTrue(screen.demoPlayback) + assertTrue(screen.canRenderPresentationFrame()) + initialFrame = frame.serverframe + val camera = field(screen, "camera") as com.badlogic.gdx.graphics.PerspectiveCamera + assertEquals(frame.playerstate.pmove.origin[0] * .125f + frame.playerstate.viewoffset[0], camera.position.x, .01f) + assertEquals(frame.playerstate.pmove.origin[1] * .125f + frame.playerstate.viewoffset[1], camera.position.y, .01f) + Cmd.ExecuteString("demopause") + snapshotAtPause = frame.serverframe + next() + } + 1 -> { + assertEquals(snapshotAtPause, frame!!.serverframe) + if (ticks > 10) { Cmd.ExecuteString("demopause"); next() } + } + 2 -> { + if (frame != null) { + frameAdvanced = frameAdvanced || frame.serverframe > initialFrame + } + if (state() == "DISCONNECTED") { + assertTrue(frameAdvanced, "Recorded frames must advance") + assertTrue(initialFrame > 0) + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap check") + next() + } + } + 3 -> if (state() == "ACTIVE") { + Cmd.ExecuteString("demomap check") // replace an active file session. + next() + } + 4 -> if (state() == "ACTIVE") { + Cmd.ExecuteString("demostop") + assertEquals("DISCONNECTED", state()) + assertNull(field(cake, "game3dScreen")) + Cmd.ExecuteString("demomap unsupported") + next() + } + 5 -> if (state() == "DISCONNECTED") { + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap truncated") + next() + } + 6 -> if (state() == "DISCONNECTED") { + assertNull(field(cake, "demoPlayback")) + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap missing") + assertEquals("DISCONNECTED", state()) + Gdx.app.exit() + } + } + } catch (error: Throwable) { fail(error) } + } + + private fun state() = field(cake, "networkState").toString() + private fun next() { stage++; ticks = 0; deadline = System.nanoTime() + 30_000_000_000L } + private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } + override fun dispose() { + try { if (::cake.isInitialized) cake.dispose() } catch (error: Throwable) { failure = failure ?: error } + } + }, Lwjgl3ApplicationConfiguration().apply { + setInitialVisible(false) + disableAudio(true) + setWindowedMode(640, 480) + setForegroundFPS(60) + setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) + setPauseWhenLostFocus(false) + setPauseWhenMinimized(false) + }) + failure?.let { throw it } + } finally { + host.dispose() + System.setProperty("user.home", previousHome) + } + } + + private fun field(owner: Any, name: String): Any? = owner.javaClass.getDeclaredField(name).apply { + isAccessible = true + }.get(owner) +} + +class CakeRemoteDemoPlaybackGlTest : CakeDemoPlaybackGlTest() { + override val embedded = false +} + +class CakeHostedDemoPlaybackGlTest : CakeDemoPlaybackGlTest() { + override val embedded = true +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index 7dbfad6e..fc96d00e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -14,6 +14,10 @@ import com.badlogic.gdx.graphics.profiling.GLProfiler import com.badlogic.gdx.scenes.scene2d.ui.Skin import com.badlogic.gdx.utils.ScreenUtils import com.badlogic.gdx.utils.viewport.ScreenViewport +import java.io.IOException +import jake2.qcommon.network.demo.DemoReader +import org.demoth.cake.demo.DemoPlayback +import org.demoth.cake.demo.demoLogicalPath import jake2.qcommon.Com import jake2.qcommon.Defines.* import jake2.qcommon.Globals @@ -129,6 +133,8 @@ class Cake( private var awaitingNewGame = false // Connection and download protocol state. + private var demoPlayback: DemoPlayback? = null + private var demoPaused = false private var networkState = DISCONNECTED private val handshake = ClientHandshake(network, ::connectPayload) private val localConnection get() = handshake.isLocal @@ -297,6 +303,7 @@ class Cake( // Important: like a svc_reconnect but more lightweight Cmd.AddCommand("reconnect", "Reconnect to the current server") { + if (demoPlayback != null) return@AddCommand Com.Printf("Reconnecting..\n") if (networkState == CONNECTED) { // set on "changing" command @@ -316,6 +323,20 @@ class Cake( } + Cmd.AddCommand("demomap", "Play a protocol-34 demo: demomap ") { args -> + if (args.size != 2) { + Com.Printf("usage: demomap \n") + } else { + startDemo(args[1]) + } + } + Cmd.AddCommand("demopause", "Pause or resume demo playback") { + if (demoPlayback != null) demoPaused = !demoPaused + } + Cmd.AddCommand("demostop", "Stop demo playback and return to the console") { + if (demoPlayback != null) dropToConsole() + } + Cmd.AddCommand("connect", "Connect to a remote server") { val server = sanitizeConnectTarget(it) if (server == null) { @@ -435,6 +456,7 @@ class Cake( * see jake2.server.ServerHost#SV_ExecuteUserCommand */ Cmd.AddCommand("cmd", "(internal) Forward a command to the connected server") { + if (demoPlayback != null) return@AddCommand if (networkState != CONNECTED && networkState != ACTIVE) { Com.Warn("Cannot cmd '${it}', not connected") } else { @@ -451,7 +473,7 @@ class Cake( Com.Warn("precache - during the game!\n") // todo: warning return@AddCommand } - val precache_spawncount = it[1].toInt() + val precache_spawncount = it.getOrNull(1)?.toIntOrNull() ?: if (demoPlayback != null) 0 else return@AddCommand val activeScreen = game3dScreen if (activeScreen == null) { @@ -462,7 +484,7 @@ class Cake( activeScreen.precache() } catch (error: RuntimeException) { val missingResource = findMissingResourceFailure(error) - if (missingResource != null && downloads.requestMissingResource( + if (demoPlayback == null && missingResource != null && downloads.requestMissingResource( missingResource.path, fileResolver.gamemod, precache_spawncount, )) { return@AddCommand @@ -473,7 +495,8 @@ class Cake( downloads.precacheCompleted() // we are ready to start the game! - netchan.queueReliable(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) + if (demoPlayback == null) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) } // endregion @@ -493,6 +516,53 @@ class Cake( return result } + private fun startDemo(name: String) { + val path = demoLogicalPath(name) + if (path == null) { + Com.Warn("Invalid demo path: $name\n") + return + } + val input = try { + fileResolver.openContent(path) ?: throw IOException("Demo not found in active profile: $path") + } catch (error: IOException) { + Com.Warn("${error.message}\n") + return + } + try { + disconnect() + } catch (error: RuntimeException) { + input.close() + throw error + } + demoPlayback = DemoPlayback(DemoReader(input)) + demoPaused = false + networkState = CONNECTED + consoleVisible = false + menuVisible = false + Com.Printf("Playing $path\n") + } + + private fun updateDemo(deltaSeconds: Float) { + val playback = demoPlayback ?: return + try { + val running = playback.update(if (demoPaused || menuVisible || applicationPaused) 0f else deltaSeconds) { messages -> + parseServerMessage(messages) + Cbuf.Execute() + demoPlayback === playback + } + if (demoPlayback !== playback) return + if (!running) { + Com.Printf("Demo completed\n") + dropToConsole() + return + } + game3dScreen?.setDemoTime(playback.presentationTimeMillis, playback.interpolationSeconds) + } catch (error: IOException) { + Com.Warn("Demo playback failed: ${error.message}\n") + dropToConsole() + } + } + private fun beginConnection(target: NetworkAddress, local: Boolean) { if (!handshake.begin(target)) { Com.Warn("Could not open the client UDP socket\n") @@ -513,8 +583,12 @@ class Cake( /** Reset client state without closing the hosted server or discarding its reconnect traffic. */ private fun resetClientConnection() { + val wasDemo = demoPlayback != null + demoPlayback?.close() + demoPlayback = null + demoPaused = false Com.Printf("Disconnecting from server...\n") - if (networkState == CONNECTED || networkState == ACTIVE) { + if (!wasDemo && (networkState == CONNECTED || networkState == ACTIVE)) { transmitAndRecord(listOf(StringCmdMessage(StringCmdMessage.DISCONNECT))) } game3dScreen?.stopAudio() @@ -530,7 +604,7 @@ class Cake( } private fun transmitAndRecord(unreliable: Collection?) { - if (netchan.transmit(unreliable)) { + if (demoPlayback == null && netchan.transmit(unreliable)) { networkDebugSampler.recordOutbound(netchan.lastSentSize, Globals.curtime) } } @@ -602,6 +676,7 @@ class Cake( uiSample.isOpen -> requireNotNull(uiSample.inputProcessor) menuVisible -> menuStages.inputProcessor + demoPlayback != null -> InputAdapter() else -> { // delegate to the game screen game3dScreen?.let { screen -> object : InputProcessor by screen {} } @@ -665,28 +740,32 @@ class Cake( // Match the legacy client ordering: process incoming packets and any stuffed commands first, // then react in the same frame by sending reliable/new packets or a reconnect challenge. - CL_ReadPackets() + if (demoPlayback == null) CL_ReadPackets() Cbuf.Execute() // Commands may have started/stopped a map. Tick once on this thread before sending // new client input; the next frame reads server replies (no second command pump). - host.setPaused(menuVisible || consoleVisible || uiSample.isOpen || applicationPaused) - host.update(hostClock.advance(Gdx.graphics.rawDeltaTime, - Globals.fixedtime.typedValue, Globals.timescale.typedValue)) - // Console/startup commands can start a Host without going through a menu. - // Existing local transitions and remote connections retain their handshake paths. - if (networkState == DISCONNECTED && host.state == HostState.RUNNING) { - awaitingNewGame = false - beginConnection(network.localPeer, local = true) - menuVisible = false - consoleVisible = false - } - if (localConnection && host.state == HostState.STOPPED) { - dropToConsole() - return + if (demoPlayback != null) { + updateDemo(deltaSeconds) + } else { + host.setPaused(menuVisible || consoleVisible || uiSample.isOpen || applicationPaused) + host.update(hostClock.advance(Gdx.graphics.rawDeltaTime, + Globals.fixedtime.typedValue, Globals.timescale.typedValue)) + // Console/startup commands can start a Host without going through a menu. + // Existing local transitions and remote connections retain their handshake paths. + if (networkState == DISCONNECTED && host.state == HostState.RUNNING) { + awaitingNewGame = false + beginConnection(network.localPeer, local = true) + menuVisible = false + consoleVisible = false + } + if (localConnection && host.state == HostState.STOPPED) { + dropToConsole() + return + } + game3dScreen?.updateLocalInput(deltaSeconds) + runClientCommandSteps(deltaSeconds) + handshake.tick(deltaSeconds) } - game3dScreen?.updateLocalInput(deltaSeconds) - runClientCommandSteps(deltaSeconds) - handshake.tick(deltaSeconds) if (!drawable) return if (uiViewport.setReferenceHeight(uiSize.typedValue)) { @@ -704,7 +783,7 @@ class Cake( netchan.outgoingSequence, Globals.curtime ) - game3dScreen?.render(deltaSeconds) + game3dScreen?.render(if (demoPlayback != null && (demoPaused || menuVisible || applicationPaused)) 0f else deltaSeconds) } val activeScreen = game3dScreen @@ -1121,6 +1200,7 @@ class Cake( // NB: it is not sent during map change (instead - StuffTextMessage.command("reconnect")) // sent on server restart is ReconnectMessage -> { + if (demoPlayback != null) return@forEach Com.Printf("ReconnectMessage: Reconnecting..\n") beginMapTransitionRetainingConfigAssets() networkState = CONNECTING @@ -1131,7 +1211,7 @@ class Cake( if (clDebugStufftext.typedValue) { Com.Printf("svc_stufftext: ${Com.makePrintable(msg.text)}\n") } - val filtered = filterServerStuffText(msg.text) + val filtered = filterServerStuffText(msg.text, demoPlayback = demoPlayback != null) filtered.rejectedCommands.forEach { Com.Warn("Ignoring unexpected server stufftext command: '${Com.makePrintable(it)}'") } @@ -1146,6 +1226,8 @@ class Cake( // new game is starting Cbuf.Execute() resetClientStateForServerData() + game3dScreen?.demoPlayback = demoPlayback != null + if (demoPlayback != null) networkState = CONNECTED netchan.clearQueuedReliable() menuStages.hideContentPreviews() @@ -1168,6 +1250,7 @@ class Cake( } is DownloadMessage -> { + if (demoPlayback != null) return@forEach if (msg is DownloadMessage.Chunk && msg.data.size > 0) { networkDebugSampler.recordDownload(msg.data.size, Globals.curtime) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt b/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt index 07db646d..ba9cfd0f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt @@ -10,12 +10,12 @@ internal data class ServerStuffTextFilterResult( val rejectedCommands: List, ) -internal fun filterServerStuffText(text: String): ServerStuffTextFilterResult { +internal fun filterServerStuffText(text: String, demoPlayback: Boolean = false): ServerStuffTextFilterResult { val accepted = mutableListOf() val rejected = mutableListOf() splitStuffTextCommands(text).forEach { command -> - if (isExpectedServerStuffTextCommand(command)) { + if (isExpectedServerStuffTextCommand(command, demoPlayback)) { accepted += command } else { rejected += command @@ -28,7 +28,7 @@ internal fun filterServerStuffText(text: String): ServerStuffTextFilterResult { ) } -private fun isExpectedServerStuffTextCommand(command: String): Boolean { +private fun isExpectedServerStuffTextCommand(command: String, demoPlayback: Boolean): Boolean { val args = Cmd.TokenizeString(command, false) if (args.isEmpty()) { return true @@ -38,7 +38,7 @@ private fun isExpectedServerStuffTextCommand(command: String): Boolean { return when (args[0]) { "changing", "reconnect" -> args.size == 1 "cmd" -> args.size > 1 - "precache" -> args.size == 2 && args[1].toIntOrNull() != null + "precache" -> (demoPlayback && args.size == 1) || (args.size == 2 && args[1].toIntOrNull() != null) "spectator" -> args.size == 2 && (args[1] == "0" || args[1] == "1") else -> false } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt index 400b888a..8a681759 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt @@ -58,6 +58,9 @@ class CakeFileResolver( /** Profile content only, without remote downloads or bundled asset fallbacks. */ internal fun resolveContent(path: String): FileHandle? = vfsAssetSource.resolve(path) + /** Sequential profile content, including archive entries; caller owns the returned stream. */ + internal fun openContent(path: String): java.io.InputStream? = vfsAssetSource.openContent(path) + /** Winning profile entries only; listing does not extract packages or load media. */ fun listContentEntries(): List = vfsAssetSource.listEntries() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt index d1401974..5df6a29b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt @@ -81,6 +81,17 @@ internal class CakeVfsAssetSource { return vfs.listEntries() } + /** Open profile content directly, retaining the VFS handle until the caller closes the stream. */ + @Synchronized + fun openContent(path: String): java.io.InputStream? { + val result = vfs.openRead(path, jake2.qcommon.vfs.VfsOpenOptions.DEFAULT) + if (!result.success) return null + val handle = result.value ?: return null + return object : java.io.FilterInputStream(handle.inputStream()) { + override fun close() = handle.close() + } + } + @Synchronized fun resolve(path: String): FileHandle? { val lookup = vfs.resolve(path) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt new file mode 100644 index 00000000..59579675 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt @@ -0,0 +1,10 @@ +package org.demoth.cake.demo + +/** Console names resolve within the active profile's demos directory, including archived entries. */ +internal fun demoLogicalPath(name: String): String? { + val normalized = name.replace('\\', '/') + if (normalized.isBlank() || normalized.startsWith('/') || ':' in normalized) return null + if (normalized.split('/').any { it.isEmpty() || it == "." || it == ".." }) return null + val path = if (normalized.startsWith("demos/", ignoreCase = true)) normalized else "demos/$normalized" + return if (path.endsWith(".dm2", ignoreCase = true)) path else "$path.dm2" +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index fa08cbd0..67d71e03 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -332,6 +332,13 @@ class Game3dScreen( inputManager.resetMouseLookReference() } + var demoPlayback: Boolean = false + + fun setDemoTime(timeMillis: Int, interpolationSeconds: Float) { + entityManager.time = timeMillis + entityManager.lerpAcc = interpolationSeconds + } + override fun render(delta: Float) { if (Gdx.graphics.backBufferWidth <= 0 || Gdx.graphics.backBufferHeight <= 0) return // A screen can be created after the application's last resize callback. @@ -377,6 +384,7 @@ class Game3dScreen( worldPresentationRenderer.render( WorldPresentationRenderer.FrameContext( delta = delta, + demoPlayback = demoPlayback, currentTimeMs = Globals.curtime, sceneFrameBuffer = sceneFrameBuffer?.renderTarget, camera = camera, @@ -886,7 +894,7 @@ class Game3dScreen( oldZ = newZ } - val predictionEnabled = (currentState.pmove.pm_flags.toInt() and Defines.PMF_NO_PREDICTION) == 0 + val predictionEnabled = !demoPlayback && (currentState.pmove.pm_flags.toInt() and Defines.PMF_NO_PREDICTION) == 0 val interpolatedX: Float val interpolatedY: Float val interpolatedZ: Float @@ -1098,7 +1106,7 @@ class Game3dScreen( soundMessageHandler.playEntityEventSounds() // Cross-reference: old `CL_ents.parsePacketEntities` calls `CL_pred.CheckPredictionError` // once a valid frame has been fully reconstructed. - prediction.onServerFrameParsed(entityManager.currentFrame) + if (!demoPlayback) prediction.onServerFrameParsed(entityManager.currentFrame) } return validMessage } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt index cd5dd62d..44667bd7 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt @@ -52,6 +52,7 @@ internal class WorldPresentationRenderer { */ data class FrameContext( val delta: Float, + val demoPlayback: Boolean = false, val currentTimeMs: Int, val sceneFrameBuffer: FrameBuffer?, val camera: PerspectiveCamera, @@ -104,7 +105,7 @@ internal class WorldPresentationRenderer { // Cross-reference: old frame order in `CL.Frame()` calls `CL_pred.PredictMovement()` // before calculating render view (`CL_ents.CalcViewValues`). - context.prediction.predictMovement( + if (!context.demoPlayback) context.prediction.predictMovement( context.entityManager.currentFrame, context.inputManager, context.gameConfig.playerConfiguration.playerIndex, @@ -211,7 +212,7 @@ internal class WorldPresentationRenderer { renderedTranslucentSprites++ } } - context.entityManager.lerpAcc += context.delta + if (!context.demoPlayback) context.entityManager.lerpAcc += context.delta } finally { context.modelBatch.end() } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt index d89c4693..6e1a6467 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt @@ -9,7 +9,7 @@ class CakeCommandOrderTest { @Test fun renderExecutesCommandBufferBeforeSendingUpdates() { - val source = readCakeSource() + val source = readCakeSource().substringAfter("override fun render()") val readPacketsIdx = source.indexOf("CL_ReadPackets()") val executeBufferIdx = source.indexOf("Cbuf.Execute()") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt index 232c5ba3..fd3e8753 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt @@ -5,6 +5,14 @@ import org.junit.jupiter.api.Test class ServerStuffTextPolicyTest { + @Test + fun argumentlessPrecacheIsOnlyAllowedForDemoPlayback() { + assertEquals(listOf("precache"), filterServerStuffText("precache").rejectedCommands) + val demo = filterServerStuffText("precache; exec bad.cfg; precache nope", demoPlayback = true) + assertEquals(listOf("precache"), demo.acceptedCommands) + assertEquals(listOf("exec bad.cfg", "precache nope"), demo.rejectedCommands) + } + @Test fun acceptsKnownConnectionFlowCommands() { val result = filterServerStuffText("changing\nreconnect\ncmd configstrings 3 0\nprecache 3\nspectator 1") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt index 275c2a00..1e394a4d 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt @@ -53,6 +53,19 @@ class CakeFileResolverTest { assertTrue(resolver.listContentEntries().isEmpty()) } + @Test + fun streamsDemoContentFromLooseFilesAndArchives() { + val bytes = byteArrayOf(0, 0, 0, 0, -1, -1, -1, -1) + createFile("baseq2/demos/loose.dm2", bytes) + writePak(temp.resolve("baseq2/pak0.pak"), linkedMapOf("demos/packed.dm2" to bytes)) + val resolver = CakeFileResolver(temp.toString()) + for (path in listOf("demos/loose.dm2", "demos/packed.dm2")) { + resolver.openContent(path)!!.use { assertTrue(bytes.contentEquals(it.readBytes())) } + } + assertNull(resolver.openContent("demos/missing.dm2")) + assertNull(resolver.openContent("../outside.dm2")) + } + @Test fun resolvesExactCaseInGamemod() { setFsDebugLoaders("0") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt new file mode 100644 index 00000000..cd84b805 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt @@ -0,0 +1,15 @@ +package org.demoth.cake.demo + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DemoPathTest { + @Test fun `console demo names resolve inside profile demos directory`() { + assertEquals("demos/test.dm2", demoLogicalPath("test")) + assertEquals("demos/test.dm2", demoLogicalPath("demos/test.dm2")) + assertEquals("demos/series/test.dm2", demoLogicalPath("series\\test")) + for (path in listOf("", "/tmp/test", "../test", "demos/../../test", "C:\\demo", "demos//test")) { + assertNull(demoLogicalPath(path), path) + } + } +} From bd85f98d6466ecf43434bf560c388f8d594834e1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 17:42:55 +0100 Subject: [PATCH 04/42] Harden demo playback timing and failure cleanup --- cake/README.md | 25 +++++++ .../jake2/server/CakeDemoPlaybackGlTest.kt | 70 ++++++++++++++++++- .../src/main/kotlin/org/demoth/cake/Cake.kt | 16 ++++- .../demoth/cake/assets/CakeVfsAssetSource.kt | 6 +- .../org/demoth/cake/demo/DemoPlayback.kt | 15 +++- .../demoth/cake/stages/ingame/Game3dScreen.kt | 22 +++--- .../ingame/IngameSoundMessageHandler.kt | 3 +- .../ingame/effects/ClientEffectsSystem.kt | 17 ++--- .../ingame/effects/EffectParticleSystem.kt | 5 +- .../org/demoth/cake/stages/ingame/hud/Hud.kt | 7 +- .../org/demoth/cake/demo/DemoPlaybackTest.kt | 15 +++- info/Networking.md | 5 +- 12 files changed, 174 insertions(+), 32 deletions(-) diff --git a/cake/README.md b/cake/README.md index e169aa06..cc52af99 100644 --- a/cake/README.md +++ b/cake/README.md @@ -159,6 +159,31 @@ the singleplayer respawn action opens Load Game automatically. remain visible in the menu so an invalid save or failed restore can be retried. Deathmatch does not create autosaves. Coop save/load remains console-only. +## Demo playback + +Both `cake-client` and `cake-fullgame` play protocol-34 client recordings (`.dm2`) +without running a game server. Select a profile containing the recording's maps, +models, and sounds, then open the client console: + +- `demomap example` — play `demos/example.dm2` from the active profile's game data. +- `demomap demos/folder/example.dm2` — play a nested demo, including archive entries. +- `demopause` — toggle pause; opening the menu or suspending the application also pauses playback. +- `demostop` or `disconnect` — stop and return to the console. + +EOF and playback errors return to the console. Starting another demo replaces the +current session. The camera follows the recorded player; movement and mouse look +do not control playback. Opening the console leaves playback running. + +Protocol-26 demos shipped with older Quake II releases, extended/rerelease +protocols, and cinematic recordings are unsupported. For a compatible test file, +record a session in Yamagi using `record example`, then `stop`, and copy its +`demos/example.dm2` into the selected profile's game-data directory. + +The hidden-window playback integration tests accept an external recording through +`CAKE_TEST_DEMO=/absolute/path/example.dm2`, alongside `CAKE_HOST_GAME_TESTS=1` +and `CAKE_TEST_BASEDIR`. They run the untouched recording and controlled view +updates in both host configurations; game content and recordings stay outside git. + ## Subsystem Guides - [Desktop video configuration](cake-client/README.md) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt index e04453b5..d0e91490 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt @@ -5,6 +5,15 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.sizebuf_t +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder import jake2.qcommon.exec.Cmd import jake2.qcommon.network.ConnectionChannel import org.demoth.cake.Cake @@ -45,6 +54,8 @@ abstract class CakeDemoPlaybackGlTest { } val bytes = Files.readAllBytes(demo) Files.write(content.resolve("demos/check.dm2"), bytes) + Files.write(content.resolve("demos/motion.dm2"), motionDemo(bytes)) + Files.write(content.resolve("demos/missing-assets.dm2"), missingAssetsDemo()) Files.write(content.resolve("demos/truncated.dm2"), bytes.copyOf(bytes.size - 5)) val unsupported = bytes.copyOf().apply { this[5] = 26 } // first record begins with svc_serverdata + protocol. Files.write(content.resolve("demos/unsupported.dm2"), unsupported) @@ -69,6 +80,7 @@ abstract class CakeDemoPlaybackGlTest { private var deadline = System.nanoTime() + 30_000_000_000L private var updatesAtOpen = 0 private var snapshotAtPause = 0 + private var timeAtPause = 0 override fun create() { try { @@ -108,10 +120,12 @@ abstract class CakeDemoPlaybackGlTest { assertEquals(frame.playerstate.pmove.origin[1] * .125f + frame.playerstate.viewoffset[1], camera.position.y, .01f) Cmd.ExecuteString("demopause") snapshotAtPause = frame.serverframe + timeAtPause = field(screen, "demoTimeMillis") as Int next() } 1 -> { assertEquals(snapshotAtPause, frame!!.serverframe) + assertEquals(timeAtPause, field(screen, "demoTimeMillis")) if (ticks > 10) { Cmd.ExecuteString("demopause"); next() } } 2 -> { @@ -122,11 +136,20 @@ abstract class CakeDemoPlaybackGlTest { assertTrue(frameAdvanced, "Recorded frames must advance") assertTrue(initialFrame > 0) assertEquals(true, field(cake, "consoleVisible")) - Cmd.ExecuteString("demomap check") + Cmd.ExecuteString("demomap motion") next() } } - 3 -> if (state() == "ACTIVE") { + 3 -> if (state() == "ACTIVE" && ticks > 20) { + val current = frame!!.playerstate + val previous = entities.previousFrame!!.playerstate + val lerp = (entities.lerpAcc * 10).coerceIn(0f, 1f) + val camera = field(screen, "camera") as com.badlogic.gdx.graphics.PerspectiveCamera + val expectedX = (previous.pmove.origin[0] + lerp * (current.pmove.origin[0] - previous.pmove.origin[0])) * .125f + assertEquals(expectedX, camera.position.x, .01f, "Camera must follow recorded movement despite prediction flags allowing local prediction") + val yaw = (previous.viewangles[1] + lerp * (current.viewangles[1] - previous.viewangles[1])) * kotlin.math.PI / 180 + assertEquals(kotlin.math.cos(yaw).toFloat(), camera.direction.x, .001f) + assertEquals(kotlin.math.sin(yaw).toFloat(), camera.direction.y, .001f) Cmd.ExecuteString("demomap check") // replace an active file session. next() } @@ -145,6 +168,12 @@ abstract class CakeDemoPlaybackGlTest { 6 -> if (state() == "DISCONNECTED") { assertNull(field(cake, "demoPlayback")) assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap missing-assets") + next() + } + 7 -> if (state() == "DISCONNECTED") { + assertNull(field(cake, "game3dScreen")) + assertEquals(true, field(cake, "consoleVisible")) Cmd.ExecuteString("demomap missing") assertEquals("DISCONNECTED", state()) Gdx.app.exit() @@ -175,6 +204,43 @@ abstract class CakeDemoPlaybackGlTest { } } + /** Controlled view deltas supplement the untouched Yamagi interoperability recording. */ + private fun motionDemo(bytes: ByteArray): ByteArray { + val output = ByteArrayOutputStream() + var frame = 0 + DemoReader(ByteArrayInputStream(bytes)).use { reader -> + while (true) { + val record = reader.read() + if (record == DemoReader.Record.End) break + val messages = (MessageBodyCodec.decodeServerBody((record as DemoReader.Record.Body).bytes) as MessageDecodeResult.Decoded).messages + val buffer = sizebuf_t(1400) + messages.forEach { message -> + val modified = if (message is PlayerInfoMessage) { + frame++ + message.copy(origin = PlayerDeltaVector(1000 + frame * 8, 0, 0), + movementFlags = 0, viewOffset = PlayerDeltaVector(0, 0, 0), + viewAngles = PlayerDeltaVector(0, frame * 1000, 0), kickAngles = PlayerDeltaVector(0, 0, 0)) + } else message + modified.writeTo(buffer) + } + output.write(int(buffer.cursize)) + output.write(buffer.data, 0, buffer.cursize) + } + } + output.write(int(-1)) + return output.toByteArray() + } + + private fun missingAssetsDemo(): ByteArray { + val buffer = sizebuf_t(1400) + listOf(ServerDataMessage(34, 1, true, "baseq2", 0, "Missing content"), + ConfigStringMessage(Defines.CS_MODELS + 1, "maps/cake-nonexistent-demo-map.bsp"), + StuffTextMessage("precache\n")).forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + int(-1) + } + + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + private fun field(owner: Any, name: String): Any? = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index fc96d00e..837e827b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -560,6 +560,10 @@ class Cake( } catch (error: IOException) { Com.Warn("Demo playback failed: ${error.message}\n") dropToConsole() + } catch (error: RuntimeException) { + // Asset loading and applying recorded state can also reject structurally valid bodies. + Com.Warn("Demo playback failed: ${error.message}\n") + dropToConsole() } } @@ -583,10 +587,13 @@ class Cake( /** Reset client state without closing the hosted server or discarding its reconnect traffic. */ private fun resetClientConnection() { - val wasDemo = demoPlayback != null - demoPlayback?.close() + val playback = demoPlayback + val wasDemo = playback != null demoPlayback = null demoPaused = false + try { playback?.close() } catch (error: IOException) { + Com.Warn("Could not close demo: ${error.message}\n") + } Com.Printf("Disconnecting from server...\n") if (!wasDemo && (networkState == CONNECTED || networkState == ACTIVE)) { transmitAndRecord(listOf(StringCmdMessage(StringCmdMessage.DISCONNECT))) @@ -1242,6 +1249,7 @@ class Cake( // delegate all game-related updates to the game screen is FrameHeaderMessage -> { + if (demoPlayback != null) game3dScreen?.setDemoTime(msg.frameNumber * 100 - 100, 0f) game3dScreen?.processServerFrameHeader(msg) } @@ -1283,7 +1291,9 @@ class Cake( } is PacketEntitiesMessage -> { - if (game3dScreen?.processPacketEntitiesMessage(msg) == true) { + val valid = game3dScreen?.processPacketEntitiesMessage(msg) == true + if (demoPlayback != null && !valid) throw IOException("Demo snapshot depends on an unavailable frame") + if (valid) { if (networkState != ACTIVE) { networkState = ACTIVE Com.Printf("Game started!\n") diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt index 5df6a29b..d7b488ad 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt @@ -87,7 +87,11 @@ internal class CakeVfsAssetSource { val result = vfs.openRead(path, jake2.qcommon.vfs.VfsOpenOptions.DEFAULT) if (!result.success) return null val handle = result.value ?: return null - return object : java.io.FilterInputStream(handle.inputStream()) { + val input = try { handle.inputStream() } catch (error: java.io.IOException) { + try { handle.close() } catch (closeError: java.io.IOException) { error.addSuppressed(closeError) } + throw error + } + return object : java.io.FilterInputStream(input) { override fun close() = handle.close() } } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt index 54d6e41d..56698554 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt @@ -5,6 +5,8 @@ import jake2.qcommon.network.demo.DemoReader import jake2.qcommon.network.messages.server.FrameHeaderMessage import jake2.qcommon.network.messages.server.ServerDataMessage import jake2.qcommon.network.messages.server.ServerMessage +import jake2.qcommon.network.messages.server.PlayerInfoMessage +import jake2.qcommon.network.messages.server.PacketEntitiesMessage import jake2.qcommon.network.protocol.MessageBodyCodec import jake2.qcommon.network.protocol.MessageDecodeResult import java.io.Closeable @@ -74,7 +76,8 @@ class DemoPlayback(private val reader: DemoReader) : Closeable { for (data in messages.filterIsInstance()) { if (data.protocol != Defines.PROTOCOL_VERSION) throw IOException("Unsupported demo protocol ${data.protocol}; expected ${Defines.PROTOCOL_VERSION}") - if (data.playerNumber < 0) throw IOException("Cinematic demos are not supported") + if (data.playerNumber == -1) throw IOException("Cinematic demos are not supported") + if (data.playerNumber !in 0 until Defines.MAX_CLIENTS) throw IOException("Invalid demo player slot ${data.playerNumber}") } serverDataSeen = true originFrame = null @@ -86,7 +89,17 @@ class DemoPlayback(private val reader: DemoReader) : Closeable { val frames = messages.filterIsInstance() if (frames.size > 1) throw IOException("Multiple snapshots in one demo record") val frame = frames.singleOrNull() + val playerIndex = messages.indexOfFirst { it is PlayerInfoMessage } + val entitiesIndex = messages.indexOfFirst { it is PacketEntitiesMessage } + if (frame == null && (playerIndex >= 0 || entitiesIndex >= 0)) + throw IOException("Demo snapshot data has no frame header") if (frame != null) { + val headerIndex = messages.indexOf(frame) + if (playerIndex <= headerIndex || entitiesIndex <= playerIndex || + messages.count { it is PlayerInfoMessage } != 1 || messages.count { it is PacketEntitiesMessage } != 1) + throw IOException("Demo contains an incomplete snapshot") + if (previousFrame == null && frame.lastFrame > 0) + throw IOException("Demo does not start with a full snapshot") if (frame.frameNumber < 0 || frame.frameNumber > Int.MAX_VALUE / 100) throw IOException("Invalid demo frame number ${frame.frameNumber}") if (previousFrame != null && frame.frameNumber <= previousFrame!!) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index 67d71e03..143d958d 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -92,7 +92,7 @@ class Game3dScreen( private val prediction by lazy { ClientPrediction(collisionModel, entityManager, gameConfig) } private val dynamicLightSystem = DynamicLightSystem() private val audioSystem: CakeAudioSystem = FireAndForgetCakeAudioSystem( - currentTimeMsProvider = { Globals.curtime }, + currentTimeMsProvider = { presentationTimeMillis }, entityOriginProvider = { entityIndex -> entityManager.getEntityOrigin(entityIndex) }, localPlayerEntityIndexProvider = { val playerIndex = gameConfig.playerConfiguration.playerIndex @@ -116,6 +116,7 @@ class Game3dScreen( audioSystem = audioSystem, cameraProvider = { camera }, dynamicLightSystem = dynamicLightSystem, + currentTimeMsProvider = { presentationTimeMillis }, ) private val soundMessageHandler = IngameSoundMessageHandler( gameConfig = gameConfig, @@ -123,6 +124,7 @@ class Game3dScreen( effectsSystem = effectsSystem, audioSystem = audioSystem, dynamicLightSystem = dynamicLightSystem, + currentTimeMsProvider = { presentationTimeMillis }, ) private val replicatedEntityEffectCollector = ReplicatedEntityEffectCollector( entityManager = entityManager, @@ -304,7 +306,7 @@ class Game3dScreen( postProcessShader.setUniformf("u_vignetteEnabled", if (RenderTuningCvars.rPostVignette.typedValue) 1f else 0f) postProcessShader.setUniformf("u_vignetteStrength", RenderTuningCvars.rPostVignetteStrength.typedValue) postProcessShader.setUniformf("u_underwaterEnabled", if (underwaterPostEnabled) 1f else 0f) - postProcessShader.setUniformf("u_timeSeconds", Globals.curtime * 0.001f) + postProcessShader.setUniformf("u_timeSeconds", presentationTimeMillis * 0.001f) it.draw(region, 0f, 0f, width, height) } postProcessBatch.shader = previousShader @@ -333,8 +335,12 @@ class Game3dScreen( } var demoPlayback: Boolean = false + private var demoTimeMillis = 0 + private val presentationTimeMillis: Int + get() = if (demoPlayback) demoTimeMillis else Globals.curtime fun setDemoTime(timeMillis: Int, interpolationSeconds: Float) { + demoTimeMillis = timeMillis entityManager.time = timeMillis entityManager.lerpAcc = interpolationSeconds } @@ -385,7 +391,7 @@ class Game3dScreen( WorldPresentationRenderer.FrameContext( delta = delta, demoPlayback = demoPlayback, - currentTimeMs = Globals.curtime, + currentTimeMs = presentationTimeMillis, sceneFrameBuffer = sceneFrameBuffer?.renderTarget, camera = camera, modelBatch = modelBatch, @@ -438,7 +444,7 @@ class Game3dScreen( 0f, 0f, Gdx.graphics.width.toFloat(), Gdx.graphics.height.toFloat(), ) cinematicController.render( - currentTimeMs = Globals.curtime, + currentTimeMs = presentationTimeMillis, spriteBatch = spriteBatch, screenWidth = Gdx.graphics.width.toFloat(), screenHeight = Gdx.graphics.height.toFloat(), @@ -584,7 +590,7 @@ class Game3dScreen( inlineSurfaceMaterialController?.update( modelInstance = entity.modelInstance, inlineModelIndex = inlineModelIndex, - currentTimeMs = Globals.curtime, + currentTimeMs = presentationTimeMillis, lightStyleResolver = ::lightStyleValue, ) } @@ -909,7 +915,7 @@ class Game3dScreen( interpolatedX = prediction.predictedOrigin[0] + viewOffsetX - backLerp * prediction.predictionError[0] interpolatedY = prediction.predictedOrigin[1] + viewOffsetY - backLerp * prediction.predictionError[1] interpolatedZ = prediction.predictedOrigin[2] + viewOffsetZ - backLerp * prediction.predictionError[2] - - prediction.smoothedStepOffset(Globals.curtime) + prediction.smoothedStepOffset(presentationTimeMillis) } else { interpolatedX = oldX + (newX - oldX) * lerp interpolatedY = oldY + (newY - oldY) * lerp @@ -1032,7 +1038,7 @@ class Game3dScreen( if (msg.playerNumber == -1) { presentationMode = PresentationMode.CINEMATIC presentationRuntime = cinematicPresentationRuntime - cinematicController.begin(levelString, Globals.curtime) + cinematicController.begin(levelString, presentationTimeMillis) } else { presentationMode = PresentationMode.WORLD presentationRuntime = worldPresentationRuntime @@ -1042,7 +1048,7 @@ class Game3dScreen( // Cake owns the shared HUD style and injects it before serverdata handling. val resolvedHudStyle = requireNotNull(hudStyle) { "HUD style must be set before serverdata is processed" } hud?.dispose() - hud = Hud(spriteBatch, resolvedHudStyle, GameConfigLayoutDataProvider(gameConfig)) + hud = Hud(spriteBatch, resolvedHudStyle, GameConfigLayoutDataProvider(gameConfig), currentTimeMsProvider = { presentationTimeMillis }) } /** diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt index dee0cefb..0b24567f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt @@ -28,6 +28,7 @@ internal class IngameSoundMessageHandler( private val effectsSystem: ClientEffectsSystem, private val audioSystem: CakeAudioSystem, private val dynamicLightSystem: DynamicLightSystem, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) { private val loopSoundRequests = mutableListOf() @@ -304,7 +305,7 @@ internal class IngameSoundMessageHandler( green = profile.green, blue = profile.blue, lifetimeMs = profile.lifetimeMs, - currentTimeMs = Globals.curtime, + currentTimeMs = currentTimeMsProvider(), ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 9ec70346..72f8d902 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -66,10 +66,11 @@ class ClientEffectsSystem( private val audioSystem: CakeAudioSystem, private val cameraProvider: () -> Camera, private val dynamicLightSystem: DynamicLightSystem? = null, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { private val assetCatalog = EffectAssetCatalog(assetManager) private val spriteRenderer = Sp2Renderer() - private val particleSystem = EffectParticleSystem(assetManager) + private val particleSystem = EffectParticleSystem(assetManager, currentTimeMsProvider) private val q2Palette: IntArray? by lazy { if (assetManager.isLoaded("q2palette.bin", Any::class.java)) { assetManager.get("q2palette.bin", Any::class.java) as? IntArray @@ -174,7 +175,7 @@ class ClientEffectsSystem( @Suppress("UNUSED_PARAMETER") fun update(deltaSeconds: Float, _serverFrame: Int) { - val now = Globals.curtime + val now = currentTimeMsProvider() particleSystem.update(deltaSeconds, now) val iterator = activeEffects.iterator() while (iterator.hasNext()) { @@ -455,7 +456,7 @@ class ClientEffectsSystem( start = start, end = end, color = Color(0.2f, 1f, 0.25f, 1f), - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), durationMs = 110, radius = 0.8f, alpha = 0.35f, @@ -769,7 +770,7 @@ class ClientEffectsSystem( activeEffects += ExplosionMd2Effect( modelInstance = instance, type = type, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, firstFrame = firstFrame, frameCount = frameCount, @@ -797,7 +798,7 @@ class ClientEffectsSystem( cameraProvider = cameraProvider, sprite = sprite, type = type, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, firstFrame = firstFrame, frameCount = frameCount, @@ -869,7 +870,7 @@ class ClientEffectsSystem( } activeEffects += AnimatedModelEffect( modelInstance = instance, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = frameDurationMs, firstFrame = firstFrame, frameCount = frameCount, @@ -897,7 +898,7 @@ class ClientEffectsSystem( spriteRenderer = spriteRenderer, cameraProvider = cameraProvider, sprite = sprite, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = frameDurationMs, firstFrame = firstFrame, frameCount = frameCount, @@ -953,7 +954,7 @@ class ClientEffectsSystem( blue = blue, lifetimeMs = lifetimeMs, decayPerSecond = decayPerSecond, - currentTimeMs = Globals.curtime, + currentTimeMs = currentTimeMsProvider(), ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt index 642c875d..6fea3512 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt @@ -29,6 +29,7 @@ import kotlin.math.min */ class EffectParticleSystem( assetManager: AssetManager, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { private var activeCount = 0 private var capacity = INITIAL_CAPACITY @@ -108,7 +109,7 @@ class EffectParticleSystem( velX[particleIndex] = directionVector.x * speed velY[particleIndex] = directionVector.y * speed velZ[particleIndex] = directionVector.z * speed - spawnTimeMs[particleIndex] = Globals.curtime + spawnTimeMs[particleIndex] = currentTimeMsProvider() lifetimeMs[particleIndex] = max(1, lifeMs) this.startAlpha[particleIndex] = startAlpha this.endAlpha[particleIndex] = endAlpha @@ -163,7 +164,7 @@ class EffectParticleSystem( if (activeCount == 0) { return } - val nowMs = Globals.curtime + val nowMs = currentTimeMsProvider() renderer.begin(camera) var index = 0 while (index < activeCount) { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt index daf452aa..299e935c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt @@ -339,6 +339,7 @@ internal class Hud( private val spriteBatch: SpriteBatch, private val style: GameUiStyle, private val dataProvider: LayoutDataProvider, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { private companion object { // Legacy notify defaults from `client/Console` (`NUM_CON_TIMES`, `con_notifytime 3`). @@ -491,7 +492,7 @@ internal class Hud( if (itemIndex != selected) { drawTextIdTech2(textX, textY, line, true, null, screenHeight) } else { - if (((Globals.curtime / 100) and 1) != 0) { + if (((currentTimeMsProvider() / 100) and 1) != 0) { drawTextIdTech2(textX - 8, textY, Char(15).toString(), false, null, screenHeight) } drawTextIdTech2(textX, textY, line, false, null, screenHeight) @@ -535,7 +536,7 @@ internal class Hud( */ fun showPrintMessage(level: Int, text: String) { if (text.isEmpty()) return - val expiresAtMs = Globals.curtime + NOTIFY_TIMEOUT_MS + val expiresAtMs = currentTimeMsProvider() + NOTIFY_TIMEOUT_MS val alt = level == PRINT_CHAT for (line in text.replace('\r', '\n').split('\n')) { if (line.isEmpty()) continue @@ -582,7 +583,7 @@ internal class Hud( } private fun drawNotifyMessages(screenHeight: Int) { - val now = Globals.curtime + val now = currentTimeMsProvider() notifyLines.removeIf { it.expiresAtMs <= now } val recent = notifyLines.takeLast(NOTIFY_MAX_LINES) var y = 0 diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt index 4f93db72..7f43a524 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt @@ -18,7 +18,7 @@ class DemoPlaybackTest { return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) } private fun bootstrap(protocol: Int = 34) = record(ServerDataMessage(protocol, 1, true, "baseq2", 0, "test"), StuffTextMessage("precache\n")) - private fun frame(number: Int) = record(FrameHeaderMessage(number, -1, 0, 0, byteArrayOf())) + private fun frame(number: Int) = record(FrameHeaderMessage(number, -1, 0, 0, byteArrayOf()), PlayerInfoMessage(fov = 90), PacketEntitiesMessage(emptyList())) private fun playback(bytes: ByteArray) = DemoPlayback(DemoReader(ByteArrayInputStream(bytes))) @Test fun `bootstrap is immediate then frames follow recorded time and last frame remains visible`() { @@ -70,6 +70,19 @@ class DemoPlaybackTest { } } + @Test fun `incomplete snapshots invalid player slots and initial delta frames reject`() { + val header = FrameHeaderMessage(10, -1, 0, 0, byteArrayOf()) + val cases = listOf( + bootstrap() + record(header), + bootstrap() + record(PlayerInfoMessage(), PacketEntitiesMessage(emptyList())), + bootstrap() + record(header.copy(lastFrame = 9), PlayerInfoMessage(), PacketEntitiesMessage(emptyList())), + record(ServerDataMessage(34, 1, true, "baseq2", 999, "test")), + ) + for (bytes in cases) playback(bytes + int(-1)).use { demo -> + assertThrows(IOException::class.java) { demo.update(0f) { true } } + } + } + @Test fun `closing during dispatch stops all further records`() { val demo = playback(bootstrap() + frame(1) + frame(2) + int(-1)) var dispatches = 0 diff --git a/info/Networking.md b/info/Networking.md index 6e2faf60..cb4f671c 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -43,8 +43,9 @@ These carry communication over an established game connection (during playing th `MessageBodyCodec` validates the whole body before dispatch, returning all messages or a typed rejection. Malformed bodies cause the server to drop the sending client, or the receiving client to return to its console. A failed movement checksum makes -the server ignore the rest of the packet. File/demo readers retain their existing -`sizebuf_t` behavior. +the server ignore the rest of the packet. Demo playback uses the same validation +for headerless server bodies; other file readers retain their existing `sizebuf_t` +behavior. ### Non Sequenced / Connectionless / out-of-band packets From 55f0b84bfd6506418a6ef7016705c1d4b68af5cb Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:04:54 +0100 Subject: [PATCH 05/42] Decode missing protocol 34 expansion temporary effects --- cake/README.md | 2 + .../jake2/server/RogueDemoDecodingTest.kt | 42 +++++++++++++++ .../ingame/effects/ClientEffectsSystem.kt | 12 +++++ .../messages/server/TemporaryEntityCodec.kt | 32 ++++++++++++ .../server/TemporaryEntityMessages.kt | 52 ++++++++++++++++--- .../protocol/TemporaryEntityCodecTest.kt | 21 +++++++- 6 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt diff --git a/cake/README.md b/cake/README.md index cc52af99..88e2d9ce 100644 --- a/cake/README.md +++ b/cake/README.md @@ -173,6 +173,8 @@ models, and sounds, then open the client console: EOF and playback errors return to the console. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look do not control playback. Opening the console leaves playback running. +Some expansion temporary effects, including heatbeam and steam, are decoded but +not yet rendered. Protocol-26 demos shipped with older Quake II releases, extended/rerelease protocols, and cinematic recordings are unsupported. For a compatible test file, diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt new file mode 100644 index 00000000..ed741b59 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt @@ -0,0 +1,42 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.FrameHeaderMessage +import jake2.qcommon.network.messages.server.TEMessage +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import java.nio.file.Files +import java.nio.file.Path + +/** Read-only interoperability check against installed Ground Zero demos; no graphics or gameplay. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +class RogueDemoDecodingTest { + @Test fun `all three Ground Zero demos decode through the terminator`() { + val basedir = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) + val demos = (1..3).map { basedir.resolve("rogue/demos/demo$it.dm2") } + assumeTrue(demos.all(Files::isRegularFile), "Ground Zero demo data is not installed") + val effects = mutableSetOf() + for (demo in demos) { + var frames = 0 + DemoReader(Files.newInputStream(demo)).use { reader -> + while (true) { + val record = reader.read() + if (record == DemoReader.Record.End) break + val result = MessageBodyCodec.decodeServerBody((record as DemoReader.Record.Body).bytes) + assertInstanceOf(MessageDecodeResult.Decoded::class.java, result, "$demo at byte ${reader.offset}: $result") + val messages = (result as MessageDecodeResult.Decoded).messages + frames += messages.count { it is FrameHeaderMessage } + effects += messages.filterIsInstance().map { it.style } + } + } + assertTrue(frames > 0, "$demo must contain world snapshots") + } + assertTrue(Defines.TE_HEATBEAM in effects, "Exercise the heatbeam regression") + assertTrue(Defines.TE_STEAM in effects, "Exercise the steam regression") + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 72f8d902..99f4e881 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -11,6 +11,11 @@ import jake2.qcommon.Defines import jake2.qcommon.Globals import jake2.qcommon.M_Flash import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.LightningTEMessage +import jake2.qcommon.network.messages.server.FlashlightTEMessage +import jake2.qcommon.network.messages.server.ForceWallTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage import jake2.qcommon.network.messages.server.BeamOffsetTEMessage import jake2.qcommon.network.messages.server.BeamTEMessage import jake2.qcommon.network.messages.server.MuzzleFlash2Message @@ -138,6 +143,13 @@ class ClientEffectsSystem( is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) is PointTEMessage -> handlePointMessage(msg) + // These expansion layouts are decoded completely to preserve message boundaries. + // Their presentation still needs dedicated effect implementations. + is LightningTEMessage, + is FlashlightTEMessage, + is ForceWallTEMessage, + is SteamTEMessage, + is WidowBeamOutTEMessage -> Unit } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt index ae615bcf..68e86f6d 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt @@ -1,5 +1,6 @@ package jake2.qcommon.network.messages.server +import jake2.qcommon.Defines.* import jake2.qcommon.math.Vector3f import jake2.qcommon.network.protocol.DirectionCodec import jake2.qcommon.network.protocol.MessageReadException @@ -12,6 +13,20 @@ object TemporaryEntityCodec { fun writeProperties(message: TEMessage, buffer: sizebuf_t) { buffer.writeByte(message.style.toByte()) when (message) { + is LightningTEMessage -> { + buffer.writeShort(message.sourceIndex); buffer.writeShort(message.destinationIndex) + writePosition(message.origin, buffer); writePosition(message.destination, buffer) + } + is FlashlightTEMessage -> { writePosition(message.position, buffer); buffer.writeShort(message.entityIndex) } + is ForceWallTEMessage -> { + writePosition(message.origin, buffer); writePosition(message.destination, buffer); buffer.writeByte(message.color.toByte()) + } + is SteamTEMessage -> { + buffer.writeShort(message.id); buffer.writeByte(message.count.toByte()); writePosition(message.position, buffer) + buffer.writeByte(message.directionIndex.toByte()); buffer.writeByte(message.color.toByte()); buffer.writeShort(message.magnitude) + message.durationMillis?.let(buffer::writeInt) + } + is WidowBeamOutTEMessage -> { buffer.writeShort(message.id); writePosition(message.position, buffer) } is PointTEMessage -> writePosition(message.position, buffer) is BeamTEMessage -> { buffer.writeShort(message.ownerIndex) @@ -32,6 +47,10 @@ object TemporaryEntityCodec { /** Includes the server opcode and subtype byte. */ fun size(message: TEMessage): Int = when (message) { + is LightningTEMessage -> 18 + is FlashlightTEMessage, is WidowBeamOutTEMessage -> 10 + is ForceWallTEMessage -> 15 + is SteamTEMessage -> if (message.durationMillis == null) 15 else 19 is PointTEMessage -> 8 is BeamTEMessage -> 16 is BeamOffsetTEMessage -> 22 @@ -43,6 +62,19 @@ object TemporaryEntityCodec { fun read(buffer: MessageReader): TEMessage { val style = buffer.readUnsignedByte() return when (style) { + TE_LIGHTNING -> LightningTEMessage(buffer.readSignedShort(), buffer.readSignedShort(), buffer.readPosition(), buffer.readPosition()) + TE_FLASHLIGHT -> FlashlightTEMessage(buffer.readPosition(), buffer.readSignedShort()) + TE_FORCEWALL -> ForceWallTEMessage(buffer.readPosition(), buffer.readPosition(), buffer.readUnsignedByte()) + TE_STEAM -> { + val id = buffer.readSignedShort() + val count = buffer.readUnsignedByte() + val position = buffer.readPosition() + val direction = DirectionCodec.readIndex(buffer) + val color = buffer.readUnsignedByte() + val magnitude = buffer.readSignedShort() + SteamTEMessage(id, count, position, direction, color, magnitude, if (id == -1) null else buffer.readInt()) + } + TE_WIDOWBEAMOUT -> WidowBeamOutTEMessage(buffer.readSignedShort(), buffer.readPosition()) in PointTEMessage.SUBTYPES -> PointTEMessage(style, buffer.readPosition()) in BeamTEMessage.SUBTYPES -> BeamTEMessage(style, buffer.readSignedShort(), buffer.readPosition(), buffer.readPosition()) in BeamOffsetTEMessage.SUBTYPES -> BeamOffsetTEMessage(style, buffer.readSignedShort(), buffer.readPosition(), buffer.readPosition(), buffer.readPosition()) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt index f19a60ef..2f2db6a4 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt @@ -5,7 +5,7 @@ import jake2.qcommon.math.Vector3f import jake2.qcommon.network.protocol.DirectionCodec import java.util.Collections -/** Temp entity: a subtype selects one of six payload layouts. */ +/** Temp entity: a subtype selects its payload layout. */ sealed class TEMessage : ServerMessage(ServerMessageType.svc_temp_entity) { abstract val style: Int } @@ -17,7 +17,7 @@ private fun styles(vararg values: Int): Set = Collections.unmodifiableSet(v * TE_PLASMA_EXPLOSION, TE_EXPLOSION1, TE_EXPLOSION1_BIG, TE_ROCKET_EXPLOSION, * TE_ROCKET_EXPLOSION_WATER, TE_EXPLOSION1_NP, TE_BFG_EXPLOSION, TE_BFG_BIGEXPLOSION, * TE_BOSSTPORT, TE_PLAIN_EXPLOSION, TE_CHAINFIST_SMOKE, TE_TRACKER_EXPLOSION, - * TE_TELEPORT_EFFECT, TE_DBALL_GOAL, TE_WIDOWSPLASH. + * TE_TELEPORT_EFFECT, TE_DBALL_GOAL, TE_WIDOWSPLASH, TE_NUKEBLAST. */ data class PointTEMessage(override val style: Int, val position: Vector3f) : TEMessage() { init { require(style in SUBTYPES) { "Wrong point effect style: $style" } } @@ -28,16 +28,16 @@ data class PointTEMessage(override val style: Int, val position: Vector3f) : TEM TE_PLASMA_EXPLOSION, TE_EXPLOSION1, TE_EXPLOSION1_BIG, TE_ROCKET_EXPLOSION, TE_ROCKET_EXPLOSION_WATER, TE_EXPLOSION1_NP, TE_BFG_EXPLOSION, TE_BFG_BIGEXPLOSION, TE_BOSSTPORT, TE_PLAIN_EXPLOSION, TE_CHAINFIST_SMOKE, TE_TRACKER_EXPLOSION, - TE_TELEPORT_EFFECT, TE_DBALL_GOAL, TE_WIDOWSPLASH) + TE_TELEPORT_EFFECT, TE_DBALL_GOAL, TE_WIDOWSPLASH, TE_NUKEBLAST) } } -/** TE_PARASITE_ATTACK and TE_MEDIC_CABLE_ATTACK carry an owner and two endpoints. */ +/** Parasite/medic cables and player/monster heatbeams carry an owner and two endpoints. */ data class BeamTEMessage(override val style: Int, val ownerIndex: Int, val origin: Vector3f, val destination: Vector3f) : TEMessage() { init { require(style in SUBTYPES) { "Wrong beam effect style: $style" } } constructor(style: Int, ownerIndex: Int, origin: FloatArray, destination: FloatArray) : this(style, ownerIndex, Vector3f(origin), Vector3f(destination)) - companion object { @JvmField val SUBTYPES = styles(TE_PARASITE_ATTACK, TE_MEDIC_CABLE_ATTACK) } + companion object { @JvmField val SUBTYPES = styles(TE_PARASITE_ATTACK, TE_MEDIC_CABLE_ATTACK, TE_HEATBEAM, TE_MONSTER_HEATBEAM) } } data class BeamOffsetTEMessage(override val style: Int, val ownerIndex: Int, val origin: Vector3f, @@ -69,7 +69,7 @@ data class PointDirectionTEMessage(override val style: Int, val position: Vector data class TrailTEMessage(override val style: Int, val position: Vector3f, val destination: Vector3f) : TEMessage() { init { require(style in SUBTYPES) { "Wrong trail effect style: $style" } } constructor(style: Int, position: FloatArray, destination: FloatArray) : this(style, Vector3f(position), Vector3f(destination)) - companion object { @JvmField val SUBTYPES = styles(TE_BUBBLETRAIL, TE_RAILTRAIL, TE_BLUEHYPERBLASTER, TE_DEBUGTRAIL, TE_BFG_LASER) } + companion object { @JvmField val SUBTYPES = styles(TE_BUBBLETRAIL, TE_BUBBLETRAIL2, TE_RAILTRAIL, TE_BLUEHYPERBLASTER, TE_DEBUGTRAIL, TE_BFG_LASER) } } data class SplashTEMessage(override val style: Int, val count: Int, val position: Vector3f, @@ -84,3 +84,43 @@ data class SplashTEMessage(override val style: Int, val count: Int, val position this(style, count and 255, Vector3f(position), DirectionCodec.index(direction?.let(::Vector3f)), param and 255) companion object { @JvmField val SUBTYPES = styles(TE_SPLASH, TE_LASER_SPARKS, TE_WELDING_SPARKS, TE_TUNNEL_SPARKS) } } + +/** Lightning identifies both endpoints before their world positions (CL_ParseLightning). */ +data class LightningTEMessage(val sourceIndex: Int, val destinationIndex: Int, + val origin: Vector3f, val destination: Vector3f) : TEMessage() { + override val style: Int get() = TE_LIGHTNING + init { require(sourceIndex in Short.MIN_VALUE..Short.MAX_VALUE && destinationIndex in Short.MIN_VALUE..Short.MAX_VALUE) } +} + +/** Unlike a beam, the flashlight's entity identifier follows its position. */ +data class FlashlightTEMessage(val position: Vector3f, val entityIndex: Int) : TEMessage() { + override val style: Int get() = TE_FLASHLIGHT + init { require(entityIndex in Short.MIN_VALUE..Short.MAX_VALUE) } +} + +data class ForceWallTEMessage(val origin: Vector3f, val destination: Vector3f, val color: Int) : TEMessage() { + override val style: Int get() = TE_FORCEWALL + init { require(color in 0..255) } +} + +/** + * CL_ParseSteam: id -1 emits once and omits duration; every other id includes a signed millisecond duration. + * Magnitude and id are signed shorts; count/color are unsigned bytes and direction is a normal-table index. + */ +data class SteamTEMessage(val id: Int, val count: Int, val position: Vector3f, val directionIndex: Int, + val color: Int, val magnitude: Int, val durationMillis: Int? = null) : TEMessage() { + override val style: Int get() = TE_STEAM + init { + require(id in Short.MIN_VALUE..Short.MAX_VALUE && magnitude in Short.MIN_VALUE..Short.MAX_VALUE) + require(count in 0..255 && color in 0..255) + require(directionIndex in 0 until NUMVERTEXNORMALS) + require((id == -1) == (durationMillis == null)) { "Sustained steam requires duration; instant steam omits it" } + } + val direction: Vector3f get() = DirectionCodec.vector(directionIndex) +} + +/** CL_ParseWidow carries a sustain identifier and position; its lifetime is fixed by the client. */ +data class WidowBeamOutTEMessage(val id: Int, val position: Vector3f) : TEMessage() { + override val style: Int get() = TE_WIDOWBEAMOUT + init { require(id in Short.MIN_VALUE..Short.MAX_VALUE) } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt index dc3a4621..cf23327a 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt @@ -28,6 +28,19 @@ class TemporaryEntityCodecTest { Family(PointDirectionTEMessage.SUBTYPES, pointBytes + byteArrayOf(161.toByte())) { PointDirectionTEMessage(it, position, 161) }, Family(TrailTEMessage.SUBTYPES, pointBytes + endBytes) { TrailTEMessage(it, position, end) }, Family(SplashTEMessage.SUBTYPES, byteArrayOf(-1) + pointBytes + byteArrayOf(161.toByte(), -2)) { SplashTEMessage(it, 255, position, 161, 254) }, + // Independent field layouts from Yamagi CL_ParseTEnt / CL_ParseLightning / CL_ParseSteam / CL_ParseWidow. + Family(setOf(TE_LIGHTNING), byteArrayOf(0, -128, -1, 127) + pointBytes + endBytes) { + LightningTEMessage(-32768, 32767, position, end) + }, + Family(setOf(TE_FLASHLIGHT), pointBytes + byteArrayOf(-1, -1)) { FlashlightTEMessage(position, -1) }, + Family(setOf(TE_FORCEWALL), pointBytes + endBytes + byteArrayOf(-2)) { ForceWallTEMessage(position, end, 254) }, + Family(setOf(TE_STEAM), byteArrayOf(-1, -1, -1) + pointBytes + byteArrayOf(161.toByte(), -2, 0, -128)) { + SteamTEMessage(-1, 255, position, 161, 254, -32768) + }, + Family(setOf(TE_STEAM), byteArrayOf(7, 0, -1) + pointBytes + byteArrayOf(161.toByte(), -2, -1, 127, 4, 3, 2, 1)) { + SteamTEMessage(7, 255, position, 161, 254, 32767, 0x01020304) + }, + Family(setOf(TE_WIDOWBEAMOUT), byteArrayOf(0, -128) + pointBytes) { WidowBeamOutTEMessage(-32768, position) }, ) private fun write(message: TEMessage): ByteArray { @@ -53,12 +66,15 @@ class TemporaryEntityCodecTest { @Test fun `all supported subtypes retain their exact field order widths and signed owners`() { - assertEquals(45, families().flatMap { it.styles }.toSet().size) + // TE_RAILTRAIL2 (31) and TE_FLAME (32) have no wire parser in protocol 34's reference client. + assertEquals((0..55).toSet() - setOf(TE_RAILTRAIL2, TE_FLAME), families().flatMap { it.styles }.toSet()) for (family in families()) for (style in family.styles) { val message = family.value(style) val expected = byteArrayOf(3, style.toByte()) + family.payload assertArrayEquals(expected, write(message), "Style $style") assertEquals(message, read(expected)) + val decoded = body(expected + byteArrayOf(10, 2, 'o'.code.toByte(), 'k'.code.toByte(), 0)) as MessageDecodeResult.Decoded + assertEquals("ok", (decoded.messages.last() as PrintMessage).text, "Style $style must consume exactly its payload") assertEquals(message.hashCode(), read(expected).hashCode()) for (length in 1 until expected.size) { val result = body(expected.copyOf(length)) as MessageDecodeResult.Rejected @@ -134,5 +150,8 @@ class TemporaryEntityCodecTest { assertThrows(IllegalArgumentException::class.java) { family.value(255) } } assertThrows(IllegalArgumentException::class.java) { PointDirectionTEMessage(TE_GUNSHOT, position, 255) } + assertThrows(IllegalArgumentException::class.java) { SteamTEMessage(-1, 1, position, 0, 1, 60, 100) } + assertThrows(IllegalArgumentException::class.java) { SteamTEMessage(1, 1, position, 0, 1, 60) } + assertThrows(IllegalArgumentException::class.java) { SteamTEMessage(-1, 1, position, 255, 1, 60) } } } From 2fda0c409800a0309f7ca80d284546f483138032 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:17:16 +0100 Subject: [PATCH 06/42] Remove installed Rogue demo dependency from engine tests --- .../jake2/server/RogueDemoDecodingTest.kt | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt deleted file mode 100644 index ed741b59..00000000 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/RogueDemoDecodingTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package jake2.server - -import jake2.qcommon.Defines -import jake2.qcommon.network.demo.DemoReader -import jake2.qcommon.network.messages.server.FrameHeaderMessage -import jake2.qcommon.network.messages.server.TEMessage -import jake2.qcommon.network.protocol.MessageBodyCodec -import jake2.qcommon.network.protocol.MessageDecodeResult -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable -import java.nio.file.Files -import java.nio.file.Path - -/** Read-only interoperability check against installed Ground Zero demos; no graphics or gameplay. */ -@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") -class RogueDemoDecodingTest { - @Test fun `all three Ground Zero demos decode through the terminator`() { - val basedir = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) - val demos = (1..3).map { basedir.resolve("rogue/demos/demo$it.dm2") } - assumeTrue(demos.all(Files::isRegularFile), "Ground Zero demo data is not installed") - val effects = mutableSetOf() - for (demo in demos) { - var frames = 0 - DemoReader(Files.newInputStream(demo)).use { reader -> - while (true) { - val record = reader.read() - if (record == DemoReader.Record.End) break - val result = MessageBodyCodec.decodeServerBody((record as DemoReader.Record.Body).bytes) - assertInstanceOf(MessageDecodeResult.Decoded::class.java, result, "$demo at byte ${reader.offset}: $result") - val messages = (result as MessageDecodeResult.Decoded).messages - frames += messages.count { it is FrameHeaderMessage } - effects += messages.filterIsInstance().map { it.style } - } - } - assertTrue(frames > 0, "$demo must contain world snapshots") - } - assertTrue(Defines.TE_HEATBEAM in effects, "Exercise the heatbeam regression") - assertTrue(Defines.TE_STEAM in effects, "Exercise the steam regression") - } -} From e7179c76662ff6c1e7c12c4163070565097bff9c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:27:04 +0100 Subject: [PATCH 07/42] Add libGDX-backed particle runtime with resource-free tests --- .../ingame/effects/GdxParticleRuntime.kt | 174 ++++++++++++++++++ .../ingame/effects/GdxParticleRuntimeTest.kt | 148 +++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt new file mode 100644 index 00000000..f6d73259 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt @@ -0,0 +1,174 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g3d.particles.ParallelArray.ChannelDescriptor +import com.badlogic.gdx.graphics.g3d.particles.ParallelArray.FloatChannel +import com.badlogic.gdx.graphics.g3d.particles.ParticleChannels +import com.badlogic.gdx.graphics.g3d.particles.ParticleController +import com.badlogic.gdx.graphics.g3d.particles.emitters.RegularEmitter +import com.badlogic.gdx.graphics.g3d.particles.influencers.Influencer +import com.badlogic.gdx.graphics.g3d.particles.renderers.PointSpriteRenderer +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.Disposable + +/** Bridges evaluated libGDX particle channels to the world's particle draw pass. */ +fun interface ParticleSink { + fun submit(mode: ParticleRenderMode, blend: ParticleBlendMode, + x: Float, y: Float, z: Float, red: Float, green: Float, blue: Float, alpha: Float, size: Float) +} + +/** + * Message-driven particles using libGDX's controller, emitter, lifetime and channel storage. + * The caller supplies presentation time so pause and demo playback need no global clock or graphics context. + * PointSpriteRenderer allocates standard channels here; GPU submission remains in Cake's world draw pass. + */ +internal class GdxParticleRuntime( + private val particleBudget: () -> Int, + initialCapacity: Int = 1024, +) : Disposable { + private val emitter = RegularEmitter().apply { + require(initialCapacity > 0) + maxParticleCount = initialCapacity + emissionMode = RegularEmitter.EmissionMode.Disabled + duration.setLow(1f) + life.setHigh(1000f) + } + private val controller = ParticleController("Cake particles", emitter, PointSpriteRenderer(), BurstInfluencer()).apply { + init() + start() + } + private val position = channel(ParticleChannels.Position) + private val color = channel(ParticleChannels.Color) + private val scale = channel(ParticleChannels.Scale) + private val life = channel(ParticleChannels.Life) + private val motion = channel(MOTION) + private val appearance = channel(APPEARANCE) + private var previousTimeMs: Int? = null + + val liveCount: Int get() = controller.particles.size + + /** Copies inputs; acceleration is physical units/second² and alpha interpolates over the supplied lifetime. */ + fun emit( + position: Vector3, velocity: Vector3, acceleration: Vector3, color: Color, + size: Float, lifetimeMillis: Int, nowMs: Int, startAlpha: Float = 1f, endAlpha: Float = 0f, + mode: ParticleRenderMode = ParticleRenderMode.POINT_SPRITE, + blend: ParticleBlendMode = ParticleBlendMode.ALPHA, + ): Boolean { + require(lifetimeMillis > 0) + require(size.isFinite() && size > 0f && startAlpha.isFinite() && endAlpha.isFinite()) + update(nowMs) + if (liveCount >= particleBudget().coerceAtLeast(0)) return false + if (liveCount == controller.particles.capacity) { + val capacity = maxOf(liveCount + 1, controller.particles.capacity * 2) + controller.particles.setCapacity(capacity) + emitter.maxParticleCount = capacity + } + val index = liveCount + controller.activateParticles(index, 1) + controller.particles.size++ + val p = index * this.position.strideSize + this.position.data[p] = position.x; this.position.data[p + 1] = position.y; this.position.data[p + 2] = position.z + val m = index * motion.strideSize + motion.data[m] = velocity.x; motion.data[m + 1] = velocity.y; motion.data[m + 2] = velocity.z + motion.data[m + 3] = acceleration.x; motion.data[m + 4] = acceleration.y; motion.data[m + 5] = acceleration.z + val c = index * this.color.strideSize + this.color.data[c] = color.r; this.color.data[c + 1] = color.g; this.color.data[c + 2] = color.b + this.color.data[c + 3] = startAlpha.coerceIn(0f, 1f) + scale.data[index] = size + val l = index * life.strideSize + life.data[l + ParticleChannels.CurrentLifeOffset] = lifetimeMillis.toFloat() + life.data[l + ParticleChannels.TotalLifeOffset] = lifetimeMillis.toFloat() + life.data[l + ParticleChannels.LifePercentOffset] = 0f + val a = index * appearance.strideSize + appearance.data[a] = startAlpha; appearance.data[a + 1] = endAlpha + appearance.data[a + 2] = mode.id.toFloat(); appearance.data[a + 3] = blend.id.toFloat() + return true + } + + /** New particles are born after existing particles reach this time, avoiding a premature first-frame update. */ + fun update(nowMs: Int) { + val previous = previousTimeMs + previousTimeMs = nowMs + if (previous == null || nowMs <= previous) return + controller.update((nowMs.toLong() - previous).toFloat() / 1000f) + } + + fun submit(nowMs: Int, sink: ParticleSink) { + update(nowMs) + for (index in 0 until liveCount) { + val p = index * position.strideSize + val c = index * color.strideSize + val a = index * appearance.strideSize + val alpha = color.data[c + 3] + if (alpha <= 0f) continue + sink.submit(ParticleRenderMode.fromId(appearance.data[a + 2].toInt().toByte()), + ParticleBlendMode.fromId(appearance.data[a + 3].toInt().toByte()), + position.data[p], position.data[p + 1], position.data[p + 2], + color.data[c], color.data[c + 1], color.data[c + 2], alpha, scale.data[index]) + } + } + + fun clear() { + controller.end() + controller.start() + previousTimeMs = null + } + + override fun dispose() { + controller.end() + previousTimeMs = null + controller.dispose() + } + + private fun channel(descriptor: ChannelDescriptor): FloatChannel = controller.particles.getChannel(descriptor) + + /** + * Burst messages supply individual velocities, accelerations and palette colors. + * This adapter advances those values and fades alpha from the emitter's normalized life; + * libGDX owns particle expiry and moves every channel together when particles are removed. + */ + private class BurstInfluencer : Influencer() { + private lateinit var position: FloatChannel + private lateinit var motion: FloatChannel + private lateinit var color: FloatChannel + private lateinit var appearance: FloatChannel + private lateinit var life: FloatChannel + private val point = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + + override fun allocateChannels() { + position = controller.particles.addChannel(ParticleChannels.Position) + color = controller.particles.addChannel(ParticleChannels.Color) + life = controller.particles.addChannel(ParticleChannels.Life) + motion = controller.particles.addChannel(MOTION) + appearance = controller.particles.addChannel(APPEARANCE) + } + + override fun update() { + val delta = controller.deltaTime + for (index in 0 until controller.particles.size) { + val p = index * position.strideSize + val m = index * motion.strideSize + point.set(position.data[p], position.data[p + 1], position.data[p + 2]) + velocity.set(motion.data[m], motion.data[m + 1], motion.data[m + 2]) + acceleration.set(motion.data[m + 3], motion.data[m + 4], motion.data[m + 5]) + point.mulAdd(velocity, delta).mulAdd(acceleration, .5f * delta * delta) + velocity.mulAdd(acceleration, delta) + position.data[p] = point.x; position.data[p + 1] = point.y; position.data[p + 2] = point.z + motion.data[m] = velocity.x; motion.data[m + 1] = velocity.y; motion.data[m + 2] = velocity.z + val a = index * appearance.strideSize + val progress = life.data[index * life.strideSize + ParticleChannels.LifePercentOffset] + color.data[index * color.strideSize + ParticleChannels.AlphaOffset] = + (appearance.data[a] + (appearance.data[a + 1] - appearance.data[a]) * progress).coerceIn(0f, 1f) + } + } + + override fun copy(): Influencer = BurstInfluencer() + } + + companion object { + private val MOTION = ChannelDescriptor(ParticleChannels.newGlobalId(), Float::class.javaPrimitiveType, 6) + private val APPEARANCE = ChannelDescriptor(ParticleChannels.newGlobalId(), Float::class.javaPrimitiveType, 4) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt new file mode 100644 index 00000000..2ecd91d8 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt @@ -0,0 +1,148 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** Engine behavior with the real libGDX controller/emitter; no GL, Cvars, globals or assets. */ +class GdxParticleRuntimeTest { + private data class Sample(val mode: ParticleRenderMode, val blend: ParticleBlendMode, + val position: Vector3, val color: Color, val size: Float) + + private fun samples(runtime: GdxParticleRuntime, nowMs: Int): List = buildList { + runtime.submit(nowMs, ParticleSink { mode, blend, x, y, z, red, green, blue, alpha, size -> + add(Sample(mode, blend, Vector3(x, y, z), Color(red, green, blue, alpha), size)) + }) + } + + private fun movingParticle() = GdxParticleRuntime({ 10 }).apply { + emit(Vector3(1f, 2f, 3f), Vector3(4f, -2f, 8f), Vector3(2f, 3f, -4f), Color.WHITE, + size = 1f, lifetimeMillis = 2000, nowMs = 1000) + } + + @Test fun `explicit motion acts on every axis and fades over the emitter lifetime`() { + val runtime = movingParticle() + try { + val result = samples(runtime, 1500).single() + assertEquals(Vector3(3.25f, 1.375f, 6.5f), result.position) + assertEquals(.75f, result.color.a, .00001f) + } finally { runtime.dispose() } + } + + @Test fun `update frequency does not materially change motion and a paused clock freezes it`() { + val singleStep = movingParticle() + val manySteps = movingParticle() + try { + singleStep.update(2000) + for (now in 1010..2000 step 10) manySteps.update(now) + val expected = samples(singleStep, 2000).single() + val actual = samples(manySteps, 2000).single() + assertTrue(expected.position.epsilonEquals(actual.position, .001f)) + assertEquals(expected.color.a, actual.color.a, .0001f) + repeat(100) { manySteps.update(2000) } + assertEquals(actual, samples(manySteps, 2000).single()) + } finally { singleStep.dispose(); manySteps.dispose() } + } + + @Test fun `particles are not aged before birth and short pulses survive their first frame`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.update(0) + runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.RED, + size = 1f, lifetimeMillis = 1, nowMs = 1000) + runtime.update(1000) + assertEquals(1, samples(runtime, 1000).size) + assertTrue(samples(runtime, 1001).isEmpty()) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `existing particles reach the emission time before a new particle is added`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.RED, 1f, 1000, 0) + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.BLUE, 1f, 1000, 500) + val particles = samples(runtime, 500).sortedBy { it.position.x } + assertEquals(0f, particles[0].position.x) + assertEquals(1f, particles[0].color.a) + assertEquals(.5f, particles[1].position.x, .00001f) + assertEquals(.5f, particles[1].color.a, .00001f) + runtime.update(1000) + assertEquals(1, runtime.liveCount) + assertEquals(Color.BLUE.b, samples(runtime, 1000).single().color.b) + } finally { runtime.dispose() } + } + + @Test fun `lifetime expiry is independent of final opacity`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.WHITE, + 1f, 100, 0, startAlpha = .2f, endAlpha = .8f) + assertEquals(.5f, samples(runtime, 50).single().color.a, .00001f) + assertEquals(1, samples(runtime, 99).size) + assertTrue(samples(runtime, 100).isEmpty()) + } finally { runtime.dispose() } + } + + @Test fun `budgets reject excess emission without destroying particles when reduced`() { + var budget = 2 + val runtime = GdxParticleRuntime({ budget }) + try { + fun emit(nowMs: Int) = runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.WHITE, 1f, 100, nowMs) + assertTrue(emit(0)); assertTrue(emit(0)); assertFalse(emit(0)) + budget = 1 + assertFalse(emit(0)) + assertEquals(2, runtime.liveCount) + assertTrue(emit(100)) // libGDX expiry frees space before the budget check. + budget = 0 + assertFalse(emit(100)) + assertEquals(1, runtime.liveCount) + runtime.update(200) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `channel growth and removal retain copied motion colors sizes and rendering modes`() { + val runtime = GdxParticleRuntime({ 10 }, initialCapacity = 1) + try { + val position = Vector3() + val velocity = Vector3() + val acceleration = Vector3() + val color = Color() + repeat(6) { id -> + runtime.emit(position.set(id.toFloat(), 0f, 0f), velocity.set(id.toFloat(), 0f, 0f), + acceleration.set(0f, id.toFloat(), 0f), color.set(id / 10f, .2f, .3f, 1f), + size = id + 1f, lifetimeMillis = if (id % 2 == 0) 10 else 1000, nowMs = 0, + startAlpha = .8f, endAlpha = .2f, + mode = ParticleRenderMode.BILLBOARD_SPRITE, blend = ParticleBlendMode.ADDITIVE) + } + position.setZero(); velocity.setZero(); acceleration.setZero(); color.set(Color.BLACK) + val survivors = samples(runtime, 500).sortedBy { it.size } + assertEquals(listOf(2f, 4f, 6f), survivors.map { it.size }) + for ((sample, id) in survivors.zip(listOf(1, 3, 5))) { + assertEquals(ParticleRenderMode.BILLBOARD_SPRITE, sample.mode) + assertEquals(ParticleBlendMode.ADDITIVE, sample.blend) + assertEquals(id / 10f, sample.color.r) + assertEquals(.2f, sample.color.g) + assertEquals(.3f, sample.color.b) + assertEquals(.5f, sample.color.a, .00001f) + assertEquals(Vector3(id * 1.5f, id * .125f, 0f), sample.position) + } + runtime.update(1000) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `clear removes pending state and permits a new playback clock`() { + val runtime = movingParticle() + try { + runtime.update(1500) + runtime.clear() + assertEquals(0, runtime.liveCount) + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.WHITE, 1f, 1000, 0) + assertEquals(.5f, samples(runtime, 500).single().position.x, .00001f) + } finally { runtime.dispose() } + assertEquals(0, runtime.liveCount) + } +} From 85aa66030ea95187c8b8db91070e2c553efb31be Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:27:54 +0100 Subject: [PATCH 08/42] Route Cake particle emission through the libGDX runtime --- .../ingame/effects/ClientEffectsSystem.kt | 2 +- .../ingame/effects/EffectParticleSystem.kt | 191 ++++-------------- .../stages/ingame/effects/ParticleRenderer.kt | 4 +- .../cake/stages/ingame/effects/README.md | 22 +- 4 files changed, 58 insertions(+), 161 deletions(-) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 99f4e881..7c945a2d 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -188,7 +188,7 @@ class ClientEffectsSystem( @Suppress("UNUSED_PARAMETER") fun update(deltaSeconds: Float, _serverFrame: Int) { val now = currentTimeMsProvider() - particleSystem.update(deltaSeconds, now) + particleSystem.update(now) val iterator = activeEffects.iterator() while (iterator.hasNext()) { val effect = iterator.next() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt index 6fea3512..407f9ff0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt @@ -14,9 +14,9 @@ import kotlin.math.min * Lightweight world-space particle simulation/runtime used by temp effects. * * Decision summary: - * - This system owns simulation only; it no longer renders through `ModelBatch`. + * - This facade maps effect emissions to a libGDX particle controller, independently of `ModelBatch`. * - Rendering is delegated to [ParticleRenderer] in a dedicated pass to keep draw calls bounded. - * - Data is stored in SoA arrays for low allocation pressure and stable update cost. + * - libGDX particle channels own storage and keep emission free of per-particle object allocations. * * Practical goals: * - preserve legacy effect readability and per-effect color correctness, @@ -24,33 +24,27 @@ import kotlin.math.min * - favor visual parity and performance over temporary intermediate fixes. * * Notes: - * - removal uses unordered swap-with-last semantics in [removeAt], so particle iteration order is not stable. + * - libGDX removes particles by swapping channel entries, so particle iteration order is not stable. * - live particles are capped by the validated `r_particles` budget; overflow spawns are dropped. */ class EffectParticleSystem( assetManager: AssetManager, private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { - private var activeCount = 0 - private var capacity = INITIAL_CAPACITY - private var posX = FloatArray(capacity) - private var posY = FloatArray(capacity) - private var posZ = FloatArray(capacity) - private var velX = FloatArray(capacity) - private var velY = FloatArray(capacity) - private var velZ = FloatArray(capacity) - private var spawnTimeMs = IntArray(capacity) - private var lifetimeMs = IntArray(capacity) - private var startAlpha = FloatArray(capacity) - private var endAlpha = FloatArray(capacity) - private var size = FloatArray(capacity) - private var gravity = FloatArray(capacity) - private var colorR = FloatArray(capacity) - private var colorG = FloatArray(capacity) - private var colorB = FloatArray(capacity) - private var renderMode = ByteArray(capacity) - private var blendMode = ByteArray(capacity) + private val runtime = GdxParticleRuntime(RenderTuningCvars::particleBudget) private val renderer = ParticleRenderer(assetManager) + private val tempDirection = Vector3() + private val tempVelocity = Vector3() + private val tempAcceleration = Vector3() + + /** Emits explicit world-space state through libGDX's particle lifetime and storage runtime. */ + fun emit( + position: Vector3, velocity: Vector3, acceleration: Vector3, color: Color, + size: Float, lifetimeMillis: Int, startAlpha: Float = 1f, endAlpha: Float = 0f, + renderMode: ParticleRenderMode = ParticleRenderMode.POINT_SPRITE, + blendMode: ParticleBlendMode = ParticleBlendMode.ALPHA, + ): Boolean = runtime.emit(position, velocity, acceleration, color, size, lifetimeMillis, + currentTimeMsProvider(), startAlpha, endAlpha, renderMode, blendMode) /** * Emits a burst of particles with shared spawn parameters. @@ -89,71 +83,40 @@ class EffectParticleSystem( return } - val remainingBudget = RenderTuningCvars.particleBudget() - activeCount - if (remainingBudget <= 0) { - return - } - + val nowMs = currentTimeMsProvider() + runtime.update(nowMs) + val remainingBudget = RenderTuningCvars.particleBudget() - runtime.liveCount + if (remainingBudget <= 0) return val spawnCount = min(requestedCount, remainingBudget) - ensureCapacity(activeCount + spawnCount) - + tempAcceleration.set(0f, 0f, gravity) repeat(spawnCount) { - val particleIndex = activeCount++ val directionVector = randomDirection(direction, spread) val speed = randomFloat(speedMin, speedMax) - val lifeMs = randomInt(lifetimeMinMs, lifetimeMaxMs) - - posX[particleIndex] = origin.x - posY[particleIndex] = origin.y - posZ[particleIndex] = origin.z - velX[particleIndex] = directionVector.x * speed - velY[particleIndex] = directionVector.y * speed - velZ[particleIndex] = directionVector.z * speed - spawnTimeMs[particleIndex] = currentTimeMsProvider() - lifetimeMs[particleIndex] = max(1, lifeMs) - this.startAlpha[particleIndex] = startAlpha - this.endAlpha[particleIndex] = endAlpha - size[particleIndex] = randomFloat(sizeMin, sizeMax).coerceAtLeast(0.1f) - this.gravity[particleIndex] = gravity - colorR[particleIndex] = color.r - colorG[particleIndex] = color.g - colorB[particleIndex] = color.b - this.renderMode[particleIndex] = renderMode.id - this.blendMode[particleIndex] = blendMode.id + val lifeMs = max(1, randomInt(lifetimeMinMs, lifetimeMaxMs)) + tempVelocity.set(directionVector).scl(speed) + runtime.emit( + position = origin, velocity = tempVelocity, acceleration = tempAcceleration, color = color, + size = randomFloat(sizeMin, sizeMax).coerceAtLeast(.1f), + lifetimeMillis = lifeMs, nowMs = nowMs, startAlpha = startAlpha, endAlpha = endAlpha, + mode = renderMode, blend = blendMode, + ) } } /** * Advances simulation for all live particles. * - * Integration model: - * - Euler integration with per-particle Z-gravity, - * - dead particles are removed in O(1) by swap-with-last compaction. + * libGDX handles lifetime and removal; the presentation clock also freezes particle motion when paused. */ - fun update(deltaSeconds: Float, nowMs: Int) { - if (activeCount == 0) { - return - } - var index = 0 - while (index < activeCount) { - val elapsedMs = nowMs - spawnTimeMs[index] - if (elapsedMs >= lifetimeMs[index]) { - removeAt(index) - continue - } - velZ[index] += gravity[index] * deltaSeconds - posX[index] += velX[index] * deltaSeconds - posY[index] += velY[index] * deltaSeconds - posZ[index] += velZ[index] * deltaSeconds - index++ - } + fun update(nowMs: Int) { + runtime.update(nowMs) } /** * Submits all visible particles to the dedicated particle renderer. * * Rendering policy: - * - alpha is evaluated from current normalized lifetime and caller-provided alpha ramp, + * - libGDX normalized lifetime drives the caller-provided alpha ramp, * - fully transparent particles are skipped, * - render/blend mode routing is delegated to [ParticleRenderer]. */ @@ -161,97 +124,21 @@ class EffectParticleSystem( if (!RenderTuningCvars.particlesEnabled()) { return } - if (activeCount == 0) { - return - } - val nowMs = currentTimeMsProvider() + if (runtime.liveCount == 0) return renderer.begin(camera) - var index = 0 - while (index < activeCount) { - val life = ((nowMs - spawnTimeMs[index]).toFloat() / lifetimeMs[index].toFloat()).coerceIn(0f, 1f) - val alpha = startAlpha[index] + (endAlpha[index] - startAlpha[index]) * life - if (alpha <= 0f) { - index++ - continue - } - renderer.submit( - mode = ParticleRenderMode.fromId(renderMode[index]), - blend = ParticleBlendMode.fromId(blendMode[index]), - x = posX[index], - y = posY[index], - z = posZ[index], - red = colorR[index], - green = colorG[index], - blue = colorB[index], - alpha = alpha, - size = size[index], - ) - index++ - } + runtime.submit(currentTimeMsProvider(), renderer) renderer.flush() } - fun liveParticleCount(): Int = activeCount + fun liveParticleCount(): Int = runtime.liveCount fun lastRenderStats(): ParticleRenderer.Stats = renderer.lastStats override fun dispose() { - activeCount = 0 + runtime.dispose() renderer.dispose() } - private fun ensureCapacity(requiredCapacity: Int) { - if (requiredCapacity <= capacity) { - return - } - var newCapacity = capacity - while (newCapacity < requiredCapacity) { - newCapacity = newCapacity shl 1 - } - posX = posX.copyOf(newCapacity) - posY = posY.copyOf(newCapacity) - posZ = posZ.copyOf(newCapacity) - velX = velX.copyOf(newCapacity) - velY = velY.copyOf(newCapacity) - velZ = velZ.copyOf(newCapacity) - spawnTimeMs = spawnTimeMs.copyOf(newCapacity) - lifetimeMs = lifetimeMs.copyOf(newCapacity) - startAlpha = startAlpha.copyOf(newCapacity) - endAlpha = endAlpha.copyOf(newCapacity) - size = size.copyOf(newCapacity) - gravity = gravity.copyOf(newCapacity) - colorR = colorR.copyOf(newCapacity) - colorG = colorG.copyOf(newCapacity) - colorB = colorB.copyOf(newCapacity) - renderMode = renderMode.copyOf(newCapacity) - blendMode = blendMode.copyOf(newCapacity) - capacity = newCapacity - } - - private fun removeAt(index: Int) { - val last = activeCount - 1 - if (index != last) { - posX[index] = posX[last] - posY[index] = posY[last] - posZ[index] = posZ[last] - velX[index] = velX[last] - velY[index] = velY[last] - velZ[index] = velZ[last] - spawnTimeMs[index] = spawnTimeMs[last] - lifetimeMs[index] = lifetimeMs[last] - startAlpha[index] = startAlpha[last] - endAlpha[index] = endAlpha[last] - size[index] = size[last] - gravity[index] = gravity[last] - colorR[index] = colorR[last] - colorG[index] = colorG[last] - colorB[index] = colorB[last] - renderMode[index] = renderMode[last] - blendMode[index] = blendMode[last] - } - activeCount = last - } - private fun randomDirection(direction: FloatArray?, spread: Float): Vector3 { val base = if (direction != null && direction.size >= 3) { tempDirection.set(direction[0], direction[1], direction[2]).nor() @@ -281,10 +168,6 @@ class EffectParticleSystem( return min + Globals.rnd.nextInt(max - min + 1) } - companion object { - private const val INITIAL_CAPACITY = 1024 - private val tempDirection = Vector3() - } } /** diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt index 50f434e8..f6e6b58e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt @@ -44,7 +44,7 @@ import kotlin.math.tan */ class ParticleRenderer( private val assetManager: AssetManager, -) : Disposable { +) : Disposable, ParticleSink { data class Stats( val submittedParticles: Int = 0, val drawCalls: Int = 0, @@ -120,7 +120,7 @@ class ParticleRenderer( * * No draw calls happen here; GPU upload/draw is deferred to [flush]. */ - fun submit( + override fun submit( mode: ParticleRenderMode, blend: ParticleBlendMode, x: Float, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 08cc3c8a..0d83c771 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -45,9 +45,22 @@ explosion frame, skin, and alpha rules. Model effects reuse the ## Particle Simulation and Rendering -`EffectParticleSystem` stores particle positions, velocities, colour, size, alpha -ramps, and lifetimes in parallel arrays. Updates integrate velocity/gravity and -remove expired particles by swapping in the last live entry. +`EffectParticleSystem` routes burst and explicit particle emission through +`GdxParticleRuntime`. A libGDX `ParticleController` and `RegularEmitter` own particle +channels, lifetime progression and removal. Cake supplies a small influencer for +message-specific velocity, three-axis acceleration and alpha endpoints, while +`ParticleRenderer` consumes the resulting channels in the existing world draw pass. + +The presentation clock drives controller updates, including demo pause. Existing +particles advance to an emission's timestamp before new particles are added, so +short pulses survive their first frame. Emission copies supplied vectors and colors; +callers can reuse scratch values. Motion uses physical acceleration and stable +constant-acceleration integration rather than reproducing reference particle math. + +The runtime is tested directly with synthetic emissions and clocks, without GL, +game data or effect assets. libGDX's [3D particle framework](https://libgdx.com/wiki/graphics/3d/3d-particle-effects) +provides the underlying controller/emitter; the choice of stock particle batches +is deferred until the world-pass depth, blending and color requirements are checked. `r_particles` supplies the live emission budget: zero or negative values disable emission and drawing, while positive values limit new spawns to the remaining @@ -80,7 +93,8 @@ Fallback colours keep effects visible when palette data is unavailable. `TE_BFG_LASER` remains a procedural `LineBeamEffect` approximation. Parasite/medic and grapple beams use repeated segment models. Visual parity varies by effect family; unsupported styles inside recognised TE message classes can fall through -silently, while unhandled message classes produce a warning. +silently. The decoded expansion message classes with no presentation implementation +are currently ignored explicitly. Legacy references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, `CL_tent.ParseTEnt`, and `../yquake2/src/client/cl_effects.c`. From 4f773f487e8d38c4f3ab118f0d657d6a67d86114 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:48:57 +0100 Subject: [PATCH 09/42] Add bounded presentation-clock scheduling for sustained effects --- .../stages/ingame/effects/SustainScheduler.kt | 44 +++++++++++ .../ingame/effects/SustainSchedulerTest.kt | 78 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt new file mode 100644 index 00000000..c8cd9d06 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.stages.ingame.effects + +/** + * Owned scheduling for repeated effect bursts (CL_ProcessSustain), independent of graphics and wall time. + * Identifiers replace existing effects. Each update emits at most once per active effect; missed ticks + * are skipped rather than replayed after a stall. The expiry boundary is exclusive. + */ +internal class SustainScheduler(private val capacity: Int = 32) { + init { require(capacity > 0) } + + private data class Entry(val value: T, val expiresAt: Long, val intervalMillis: Int, var nextAt: Long) + private val entries = LinkedHashMap>() + val activeCount: Int get() = entries.size + + /** A nonpositive duration cancels the identifier. Expired entries never consume admission capacity. */ + fun start(id: Int, value: T, nowMs: Int, durationMillis: Int, intervalMillis: Int): Boolean { + require(intervalMillis > 0) + entries.values.removeAll { nowMs.toLong() >= it.expiresAt } + if (durationMillis <= 0) { + entries.remove(id) + return true + } + if (id !in entries && entries.size >= capacity) return false + entries[id] = Entry(value, nowMs.toLong() + durationMillis, intervalMillis, nowMs.toLong()) + return true + } + + fun update(nowMs: Int, emit: (T) -> Unit) { + val now = nowMs.toLong() + val iterator = entries.values.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now >= entry.expiresAt) { + iterator.remove() + } else if (now >= entry.nextAt) { + // Keep the emission cadence while bounding work to one burst after a missed interval. + entry.nextAt += ((now - entry.nextAt) / entry.intervalMillis + 1) * entry.intervalMillis + emit(entry.value) + } + } + } + + fun clear() = entries.clear() +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt new file mode 100644 index 00000000..c594c64c --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt @@ -0,0 +1,78 @@ +package org.demoth.cake.stages.ingame.effects + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SustainSchedulerTest { + @Test fun `bursts start immediately follow cadence and stop at expiry`() { + val scheduler = SustainScheduler() + val bursts = mutableListOf() + scheduler.start(1, "steam", 1000, 300, 100) + scheduler.update(1000, bursts::add) + repeat(20) { scheduler.update(1000, bursts::add) } // A paused clock cannot repeat emission. + scheduler.update(1099, bursts::add) + assertEquals(listOf("steam"), bursts) + scheduler.update(1100, bursts::add) + scheduler.update(1200, bursts::add) + assertEquals(3, bursts.size) + scheduler.update(1300, bursts::add) + assertEquals(3, bursts.size) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `late updates emit once skip missed ticks and retain cadence`() { + val scheduler = SustainScheduler() + val bursts = mutableListOf() + scheduler.start(1, 7, 0, 20000, 100) + scheduler.update(0, bursts::add) + scheduler.update(10025, bursts::add) + assertEquals(listOf(7, 7), bursts) + scheduler.update(10099, bursts::add) + assertEquals(2, bursts.size) + scheduler.update(10100, bursts::add) + assertEquals(3, bursts.size) + scheduler.update(30000, bursts::add) + assertEquals(3, bursts.size) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `reusing an identifier replaces state and refreshes duration even at capacity`() { + val scheduler = SustainScheduler(capacity = 1) + val bursts = mutableListOf() + assertTrue(scheduler.start(1, "old", 0, 200, 100)) + scheduler.update(0, bursts::add) + assertTrue(scheduler.start(1, "new", 150, 300, 100)) + assertFalse(scheduler.start(2, "overflow", 150, 1000, 100)) + scheduler.update(150, bursts::add) + scheduler.update(300, bursts::add) + assertEquals(listOf("old", "new", "new"), bursts) + assertEquals(1, scheduler.activeCount) + scheduler.update(450, bursts::add) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `expired and cancelled entries free slots and clear releases all scheduled emissions`() { + val scheduler = SustainScheduler(capacity = 1) + val bursts = mutableListOf() + scheduler.start(1, 1, 0, 100, 100) + assertTrue(scheduler.start(2, 2, 100, 500, 100)) // Admission prunes expired state without needing update. + assertTrue(scheduler.start(2, 2, 100, 0, 100)) + assertEquals(0, scheduler.activeCount) + scheduler.start(3, 3, 100, 500, 100) + scheduler.start(3, 3, 100, -1, 100) + assertEquals(0, scheduler.activeCount) + scheduler.start(4, 4, 100, 500, 100) + scheduler.clear() + scheduler.update(200, bursts::add) + assertTrue(bursts.isEmpty()) + } + + @Test fun `long protocol durations do not overflow into immediate expiry`() { + val scheduler = SustainScheduler() + var emitted = 0 + scheduler.start(1, 1, 1000, Int.MAX_VALUE, 100) + scheduler.update(2000) { emitted++ } + assertEquals(1, emitted) + assertEquals(1, scheduler.activeCount) + } +} From c76b6b5170db799d7475fb9dc836016e7188ef72 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:51:55 +0100 Subject: [PATCH 10/42] Present steam sustains and heatbeam impact particles --- cake/README.md | 4 +- .../ingame/effects/ClientEffectsSystem.kt | 34 ++-- .../ingame/effects/EffectParticleSystem.kt | 2 +- .../cake/stages/ingame/effects/README.md | 16 ++ .../stages/ingame/effects/SteamEffects.kt | 79 +++++++++ .../stages/ingame/effects/SteamEffectsTest.kt | 164 ++++++++++++++++++ 6 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt diff --git a/cake/README.md b/cake/README.md index 88e2d9ce..22675eaa 100644 --- a/cake/README.md +++ b/cake/README.md @@ -173,8 +173,8 @@ models, and sounds, then open the client console: EOF and playback errors return to the console. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look do not control playback. Opening the console leaves playback running. -Some expansion temporary effects, including heatbeam and steam, are decoded but -not yet rendered. +Some expansion temporary effects, including heatbeam geometry, are decoded but +not yet rendered. Steam and heatbeam impact particles are supported. Protocol-26 demos shipped with older Quake II releases, extended/rerelease protocols, and cinematic recordings are unsupported. For a compatible test file, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 7c945a2d..4c2e445d 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -83,6 +83,9 @@ class ClientEffectsSystem( null } } + private val steamEffects = SteamEffects(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) + }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -143,12 +146,12 @@ class ClientEffectsSystem( is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) is PointTEMessage -> handlePointMessage(msg) + is SteamTEMessage -> steamEffects.process(msg, currentTimeMsProvider()) // These expansion layouts are decoded completely to preserve message boundaries. // Their presentation still needs dedicated effect implementations. is LightningTEMessage, is FlashlightTEMessage, is ForceWallTEMessage, - is SteamTEMessage, is WidowBeamOutTEMessage -> Unit } } @@ -189,6 +192,7 @@ class ClientEffectsSystem( fun update(deltaSeconds: Float, _serverFrame: Int) { val now = currentTimeMsProvider() particleSystem.update(now) + steamEffects.update(now) val iterator = activeEffects.iterator() while (iterator.hasNext()) { val effect = iterator.next() @@ -230,6 +234,7 @@ class ClientEffectsSystem( fun debugActiveEffectCount(): Int = activeEffects.size override fun dispose() { + steamEffects.clear() activeEffects.forEach { it.dispose() } activeEffects.clear() particleSystem.dispose() @@ -284,6 +289,10 @@ class ClientEffectsSystem( private fun handlePointDirectionMessage(msg: PointDirectionTEMessage) { val position = toVector3(msg.position) + if (steamEffects.processImpact(msg, currentTimeMsProvider())) { + playEffectSound("sound/weapons/lashit.wav", position) + return + } when (msg.style) { Defines.TE_BLOOD, Defines.TE_MOREBLOOD, @@ -339,26 +348,6 @@ class ClientEffectsSystem( playEffectSound("sound/weapons/lashit.wav", position) } - Defines.TE_HEATBEAM_SPARKS -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 50, - paletteBase = HEATBEAM_SPARK_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_HEATBEAM_STEAM -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 20, - paletteBase = HEATBEAM_STEAM_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - Defines.TE_ELECTRIC_SPARKS -> { emitPaletteImpactParticles( origin = position, @@ -1688,8 +1677,7 @@ private const val SPARK_PARTICLE_BASE_COLOR = 0xE0 private const val SCREEN_SPARK_PARTICLE_BASE_COLOR = 0xD0 private const val SHIELD_SPARK_PARTICLE_BASE_COLOR = 0xB0 private const val SHOTGUN_PARTICLE_BASE_COLOR = 0x00 -private const val HEATBEAM_SPARK_PARTICLE_BASE_COLOR = 0x08 -private const val HEATBEAM_STEAM_PARTICLE_BASE_COLOR = 0xE0 +private val STEAM_PARTICLE_FALLBACK = Color(.75f, .75f, .75f, 1f) private const val ELECTRIC_SPARK_PARTICLE_BASE_COLOR = 0x75 private const val CHAINFIST_SMOKE_PARTICLE_BASE_COLOR = 0x00 private const val EXPLOSION_PARTICLE_BASE_COLOR = 0xE0 diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt index 407f9ff0..8185ee32 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt @@ -31,7 +31,7 @@ class EffectParticleSystem( assetManager: AssetManager, private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { - private val runtime = GdxParticleRuntime(RenderTuningCvars::particleBudget) + internal val runtime = GdxParticleRuntime(RenderTuningCvars::particleBudget) private val renderer = ParticleRenderer(assetManager) private val tempDirection = Vector3() private val tempVelocity = Vector3() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 0d83c771..9e3a7589 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -79,6 +79,22 @@ intensity/overbright multipliers. Shader sources are `assets/shaders/particle_point.vert/.frag` and `particle_billboard.vert/.frag`, loaded through `AssetManager` and the [shared resolver](../../../assets/README.md). +## Steam and Sustained Emission + +`SteamEffects` uses the shared libGDX particle runtime for instantaneous steam and +repeated steam bursts. Messages supply particle count, normal, palette range and +magnitude. Magnitude controls forward speed, lateral spread and spawn jitter; +particles fade over short lifetimes with a gentle downward acceleration. +Heatbeam impact sparks and steam use this same recipe and retain their impact sound. + +Each screen owns at most 32 steam sustains. Reusing an identifier replaces its +parameters and refreshes its duration. `SustainScheduler` emits immediately on the +next effects update, then on a 100 ms cadence until expiry. A stalled frame emits +at most one burst per active sustain, skipping missed bursts. Repeated updates at +a paused presentation time do not emit again. Nonpositive durations cancel an +identifier. Clearing sustains stops future emission; existing particles finish +fading, or are disposed with the screen's particle runtime during teardown. + ## Visual Behaviour and Current Limits Particle families select colours from `q2palette.bin`. Useful compatibility anchors: diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt new file mode 100644 index 00000000..2bbd929c --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointDirectionTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import kotlin.math.abs +import kotlin.random.Random + +/** + * Steam bursts and their message-owned sustain state (CL_ParseSteam / CL_ParticleSteamEffect). + * libGDX owns the resulting particles; clearing this owner stops future bursts without removing particles + * which are already fading. The outer effects system disposes the shared particle runtime at teardown. + */ +internal class SteamEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, + maximumSustains: Int = 32, +) { + private val sustains = SustainScheduler(maximumSustains) + private val direction = Vector3() + private val right = Vector3() + private val up = Vector3() + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3(0f, 0f, -40f) + val activeSustainCount: Int get() = sustains.activeCount + + fun process(message: SteamTEMessage, nowMs: Int) { + if (message.id == -1) { + emitBurst(message.position, message.direction, message.count, message.color, message.magnitude, nowMs) + } else { + sustains.start(message.id, message, nowMs, checkNotNull(message.durationMillis), intervalMillis = 100) + } + } + + /** Heatbeam impact steam and sparks use the steam recipe, and their existing impact sound is played by the caller. */ + fun processImpact(message: PointDirectionTEMessage, nowMs: Int): Boolean { + val (count, color) = when (message.style) { + Defines.TE_HEATBEAM_SPARKS -> 50 to 0x08 + Defines.TE_HEATBEAM_STEAM -> 20 to 0xE0 + else -> return false + } + emitBurst(message.position, message.direction, count, color, magnitude = 60, nowMs = nowMs) + return true + } + + fun update(nowMs: Int) { + sustains.update(nowMs) { message -> + emitBurst(message.position, message.direction, message.count, message.color, message.magnitude, nowMs) + } + } + + fun clear() = sustains.clear() + + private fun emitBurst(origin: Vector3f, normal: Vector3f, count: Int, color: Int, magnitude: Int, nowMs: Int) { + if (count == 0) return + direction.set(normal.x, normal.y, normal.z).nor() + // Build a stable transverse basis with libGDX vectors, including directions parallel to the vertical axis. + right.set(direction).crs(if (abs(direction.z) < .9f) Vector3.Z else Vector3.X).nor() + up.set(right).crs(direction).nor() + val speed = magnitude.toFloat() + val spread = abs(speed) / 3f + val jitter = abs(speed) * .1f + repeat(count) { + position.set(origin.x + signedRandom() * jitter, origin.y + signedRandom() * jitter, origin.z + signedRandom() * jitter) + velocity.set(direction).scl(speed).mulAdd(right, signedRandom() * spread).mulAdd(up, signedRandom() * spread) + val emitted = particles.emit(position, velocity, acceleration, + paletteColor((color + random.nextInt(8)) and 255), size = .4f + random.nextFloat() * .6f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs) + if (!emitted) return // A full/disabled particle budget drops this burst's remainder. + } + } + + private fun signedRandom(): Float = random.nextFloat() * 2f - 1f +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt new file mode 100644 index 00000000..5fb2a7af --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt @@ -0,0 +1,164 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointDirectionTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import jake2.qcommon.network.protocol.DirectionCodec +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +/** Real particle-controller behavior with synthetic messages, palette values and presentation clocks. */ +class SteamEffectsTest { + private data class Sample(val position: Vector3, val alpha: Float) + + private class Fixture(initialBudget: Int = 1000) : AutoCloseable { + var budget = initialBudget + val particles = GdxParticleRuntime({ budget }) + val paletteIndices = mutableListOf() + val steam = SteamEffects(particles, { index -> + paletteIndices += index + Color(index / 255f, .5f, .5f, 1f) + }, random = Random(12), maximumSustains = 2) + + fun advance(now: Int) { + particles.update(now) + steam.update(now) + } + + fun samples(now: Int): List = buildList { + particles.submit(now, ParticleSink { _, _, x, y, z, _, _, _, alpha, _ -> add(Sample(Vector3(x, y, z), alpha)) }) + } + + override fun close() { steam.clear(); particles.dispose() } + } + + private val origin = Vector3f(10f, 20f, 30f) + private val direction = DirectionCodec.index(Vector3f.unitX) + private fun message(id: Int = -1, count: Int = 4, magnitude: Int = 100, duration: Int? = null) = + SteamTEMessage(id, count, origin, direction, color = 100, magnitude = magnitude, durationMillis = duration) + + @Test fun `instant steam honors count palette direction magnitude and particle lifetime`() { + Fixture().use { fixture -> + fixture.steam.process(message(), 0) + assertEquals(4, fixture.particles.liveCount) + assertEquals(0, fixture.steam.activeSustainCount) + assertEquals(4, fixture.paletteIndices.size) + assertTrue(fixture.paletteIndices.all { it in 100..107 }) + val initial = fixture.samples(0) + assertTrue(initial.all { it.position.x in 0f..20f && it.position.y in 10f..30f && it.position.z in 20f..40f }) + val advanced = fixture.samples(100) + for ((before, after) in initial.zip(advanced)) { + assertEquals(10f, after.position.x - before.position.x, .001f) + assertTrue(after.alpha in 0f..1f && after.alpha < before.alpha) + } + fixture.advance(801) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `zero and signed magnitudes preserve directed motion and acceleration`() { + for (magnitude in listOf(-120, 0, 120)) Fixture().use { fixture -> + fixture.steam.process(message(count = 1, magnitude = magnitude), 0) + val first = fixture.samples(0).single().position + val second = fixture.samples(100).single().position + val third = fixture.samples(200).single().position + assertEquals(magnitude * .1f, second.x - first.x, .001f) + // Steam falls under physical acceleration, rather than using the old generic impact burst. + assertEquals(-.4f, third.z - 2f * second.z + first.z, .001f) + } + } + + @Test fun `vertical directions produce finite particles with motion along the supplied normal`() { + Fixture().use { fixture -> + fixture.steam.process(message(count = 1).copy(directionIndex = DirectionCodec.index(Vector3f.unitZ)), 0) + val first = fixture.samples(0).single().position + val second = fixture.samples(100).single().position + assertTrue(second.x.isFinite() && second.y.isFinite() && second.z.isFinite()) + assertEquals(9.8f, second.z - first.z, .001f) + } + } + + @Test fun `sustained steam emits every hundred milliseconds without duplicating paused updates`() { + Fixture().use { fixture -> + fixture.steam.process(message(id = 7, count = 2, duration = 250), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.advance(0) + repeat(30) { fixture.advance(0) } + fixture.advance(99) + assertEquals(2, fixture.particles.liveCount) + fixture.advance(100) + assertEquals(4, fixture.particles.liveCount) + fixture.advance(200) + assertEquals(6, fixture.particles.liveCount) + fixture.advance(250) + assertEquals(0, fixture.steam.activeSustainCount) + fixture.advance(1100) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `refresh replaces an identifier and clear stops future bursts while particles finish fading`() { + Fixture().use { fixture -> + fixture.steam.process(message(id = 1, count = 2, duration = 1000), 0) + fixture.advance(0) + fixture.steam.process(message(id = 1, count = 3, duration = 1000).copy(position = Vector3f(200f, 0f, 0f)), 50) + fixture.advance(50) + assertEquals(1, fixture.steam.activeSustainCount) + assertEquals(5, fixture.particles.liveCount) + assertEquals(3, fixture.samples(50).count { it.position.x in 190f..210f }) + fixture.steam.clear() + fixture.advance(150) + assertEquals(0, fixture.steam.activeSustainCount) + assertEquals(5, fixture.particles.liveCount) + fixture.advance(1000) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `stalls skip catchup bursts expired sustains stop and active effects stay bounded`() { + Fixture().use { fixture -> + for (id in 1..3) fixture.steam.process(message(id = id, count = 1, duration = 2000), 0) + assertEquals(2, fixture.steam.activeSustainCount) + fixture.advance(0) + fixture.advance(1025) + assertEquals(2, fixture.particles.liveCount) // Only one fresh burst per admitted sustain. + repeat(20) { fixture.advance(1025) } + assertEquals(2, fixture.particles.liveCount) + fixture.advance(3000) + assertEquals(0, fixture.particles.liveCount) + assertEquals(0, fixture.steam.activeSustainCount) + } + } + + @Test fun `particle budget limits bursts and zero count emits nothing`() { + Fixture(initialBudget = 3).use { fixture -> + fixture.steam.process(message(count = 0), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.steam.process(message(count = 255), 0) + assertEquals(3, fixture.particles.liveCount) + fixture.budget = 0 + fixture.advance(1000) + fixture.steam.process(message(), 1000) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `heatbeam sparks and steam use directional steam particles with their own counts and palette`() { + for ((style, count, color) in listOf(Triple(Defines.TE_HEATBEAM_SPARKS, 50, 0x08), Triple(Defines.TE_HEATBEAM_STEAM, 20, 0xE0))) { + Fixture().use { fixture -> + assertTrue(fixture.steam.processImpact(PointDirectionTEMessage(style, origin, direction), 0)) + assertEquals(count, fixture.particles.liveCount) + assertTrue(fixture.paletteIndices.all { it in color..color + 7 }) + val initial = fixture.samples(0) + val advanced = fixture.samples(100) + for ((first, second) in initial.zip(advanced)) assertEquals(6f, second.position.x - first.position.x, .001f) + assertFalse(fixture.steam.processImpact(PointDirectionTEMessage(Defines.TE_GUNSHOT, origin, direction), 100)) + assertEquals(count, fixture.particles.liveCount) + } + } + } +} From 710132f1a1a724354de38ae46dfde028596dd7ea Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:56:36 +0100 Subject: [PATCH 11/42] Add persistent beam identity and bounded segment geometry --- .../cake/stages/ingame/effects/BeamEffects.kt | 112 ++++++++++++++++++ .../stages/ingame/effects/BeamEffectsTest.kt | 103 ++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt new file mode 100644 index 00000000..1553ebfa --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt @@ -0,0 +1,112 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import kotlin.math.ceil +import kotlin.random.Random + +internal enum class BeamKind(val modelPath: String, val segmentLength: Float) { + CABLE("models/monsters/parasite/segment/tris.md2", 30f), + GRAPPLE("models/ctf/segment/tris.md2", 30f), + LIGHTNING("models/proj/lightning/tris.md2", 35f), +} + +internal data class BeamSoundRequest(val path: String, val entityIndex: Int, val channel: Int) + +internal fun interface BeamSegmentSink { + fun submit(kind: BeamKind, x: Float, y: Float, z: Float, pitch: Float, yaw: Float, roll: Float) +} + +/** + * Owned, bounded beam state (CL_ParseBeam / CL_ParseLightning / CL_AddBeams). + * Cables refresh by source entity; lightning refreshes by source AND destination. + * Geometry is rebuilt from the current view each frame, independently of new network messages. + */ +internal class BeamEffects( + private val playSound: (BeamSoundRequest) -> Unit, + private val maximumBeams: Int = 32, + private val maximumSegmentsPerBeam: Int = 128, + private val random: Random = Random.Default, +) { + init { require(maximumBeams > 0 && maximumSegmentsPerBeam > 0) } + private data class Key(val source: Int, val destination: Int?) + private data class Beam(val kind: BeamKind, val origin: Vector3f, val end: Vector3f, + val offset: Vector3f, val expiresAt: Long, val roll: Float) + private val beams = LinkedHashMap() + private val start = Vector3() + private val direction = Vector3() + val activeCount: Int get() = beams.size + + /** Returns false for unrelated messages, including heatbeams which have their own presentation. */ + fun process(message: TEMessage, nowMs: Int): Boolean { + val key: Key + val kind: BeamKind + val origin: Vector3f + val end: Vector3f + val offset: Vector3f + when (message) { + is BeamTEMessage -> { + if (message.style != Defines.TE_PARASITE_ATTACK && message.style != Defines.TE_MEDIC_CABLE_ATTACK) return false + key = Key(message.ownerIndex, null); kind = BeamKind.CABLE + origin = message.origin; end = message.destination; offset = Vector3f.zero + } + is BeamOffsetTEMessage -> { + key = Key(message.ownerIndex, null); kind = BeamKind.GRAPPLE + origin = message.origin; end = message.destination; offset = message.offset + } + is LightningTEMessage -> { + key = Key(message.sourceIndex, message.destinationIndex); kind = BeamKind.LIGHTNING + origin = message.origin; end = message.destination; offset = Vector3f.zero + } + else -> return false + } + if (key.source !in 0 until Defines.MAX_EDICTS || + (key.destination != null && key.destination !in 0 until Defines.MAX_EDICTS)) return true + if (kind == BeamKind.LIGHTNING) + playSound(BeamSoundRequest("sound/weapons/tesla.wav", key.source, Defines.CHAN_WEAPON)) + expire(nowMs) + if (key !in beams && beams.size >= maximumBeams) return true + beams[key] = Beam(kind, origin, end, offset, nowMs.toLong() + 200, beams[key]?.roll ?: random.nextFloat() * 360f) + return true + } + + fun submit(nowMs: Int, localPlayerEntity: Int?, viewOrigin: Vector3, sink: BeamSegmentSink) { + expire(nowMs) + for ((key, beam) in beams) { + if (key.source == localPlayerEntity) start.set(viewOrigin).add(0f, 0f, -22f) + else start.set(beam.origin.x, beam.origin.y, beam.origin.z) + start.add(beam.offset.x, beam.offset.y, beam.offset.z) + direction.set(beam.end.x, beam.end.y, beam.end.z).sub(start) + val length = direction.len() + if (length < .001f) continue + direction.scl(1f / length) + val yaw = MathUtils.atan2(direction.y, direction.x) * MathUtils.radiansToDegrees + val pitch = -MathUtils.atan2(direction.z, kotlin.math.sqrt(direction.x * direction.x + direction.y * direction.y)) * MathUtils.radiansToDegrees + val lightning = beam.kind == BeamKind.LIGHTNING + // Lightning models point backwards. Short bolts start at the target so they don't pierce the Tesla mine. + val usableLength = if (lightning) (length - 20f).coerceAtLeast(0f) else length + if (lightning && usableLength <= beam.kind.segmentLength) { + sink.submit(beam.kind, beam.end.x, beam.end.y, beam.end.z, pitch, yaw, roll(beam, nowMs, 0)) + continue + } + val count = ceil(usableLength / beam.kind.segmentLength).toInt().coerceIn(1, maximumSegmentsPerBeam) + val step = if (count > 1) (usableLength - beam.kind.segmentLength) / (count - 1) else 0f + for (index in 0 until count) { + val distance = index * step + sink.submit(beam.kind, start.x + direction.x * distance, start.y + direction.y * distance, + start.z + direction.z * distance, if (lightning) -pitch else pitch, + if (lightning) yaw + 180f else yaw, roll(beam, nowMs, index)) + } + } + } + + fun clear() = beams.clear() + + private fun expire(nowMs: Int) = beams.values.removeAll { nowMs.toLong() >= it.expiresAt } + + private fun roll(beam: Beam, nowMs: Int, index: Int): Float = + (beam.roll + index * 137.5f + if (beam.kind == BeamKind.LIGHTNING) (nowMs % 3000) * .12f else 0f) % 360f +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt new file mode 100644 index 00000000..10bfe5db --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt @@ -0,0 +1,103 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class BeamEffectsTest { + private data class Segment(val kind: BeamKind, val position: Vector3, val pitch: Float, val yaw: Float, val roll: Float) + private fun segments(beams: BeamEffects, now: Int, local: Int? = null, view: Vector3 = Vector3.Zero): List = buildList { + beams.submit(now, local, view, BeamSegmentSink { kind, x, y, z, pitch, yaw, roll -> + add(Segment(kind, Vector3(x, y, z), pitch, yaw, roll)) + }) + } + private fun cable(owner: Int = 1, end: Vector3f = Vector3f(90f, 0f, 0f)) = + BeamTEMessage(Defines.TE_PARASITE_ATTACK, owner, Vector3f.zero, end) + + @Test fun `refresh replaces a source beam and extends its lifetime without accumulating segments`() { + val beams = BeamEffects({}) + beams.process(cable(), 0) + assertEquals(3, segments(beams, 0).size) + beams.process(cable(end = Vector3f(60f, 0f, 0f)), 150) + assertEquals(1, beams.activeCount) + assertEquals(2, segments(beams, 200).size) + assertTrue(segments(beams, 350).isEmpty()) + assertEquals(0, beams.activeCount) + } + + @Test fun `lightning retains distinct destinations and issues entity weapon-channel sound requests`() { + val sounds = mutableListOf() + val beams = BeamEffects(sounds::add) + fun lightning(target: Int) = LightningTEMessage(7, target, Vector3f.zero, Vector3f(100f, 0f, 0f)) + beams.process(lightning(8), 0) + beams.process(lightning(9), 0) + beams.process(lightning(8), 100) + assertEquals(2, beams.activeCount) + assertEquals(3, sounds.size) + assertTrue(sounds.all { it.path == "sound/weapons/tesla.wav" && it.entityIndex == 7 && it.channel == Defines.CHAN_WEAPON }) + segments(beams, 200) + assertEquals(1, beams.activeCount) + segments(beams, 300) + assertEquals(0, beams.activeCount) + } + + @Test fun `player-origin cables follow the current view and offset between network updates`() { + val beams = BeamEffects({}) + beams.process(BeamOffsetTEMessage(Defines.TE_GRAPPLE_CABLE, 4, Vector3f(50f, 60f, 70f), + Vector3f(300f, 0f, 0f), Vector3f(1f, 2f, 3f)), 0) + assertEquals(Vector3(11f, 22f, 11f), segments(beams, 0, 4, Vector3(10f, 20f, 30f)).first().position) + assertEquals(Vector3(21f, 32f, 21f), segments(beams, 50, 4, Vector3(20f, 30f, 40f)).first().position) + assertEquals(Vector3(51f, 62f, 73f), segments(beams, 50).first().position) + } + + @Test fun `short and vertical lightning and single-segment cables produce finite transforms`() { + val beams = BeamEffects({}) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(0f, 0f, 10f)), 0) + val bolt = segments(beams, 0).single() + assertEquals(Vector3(0f, 0f, 10f), bolt.position) + assertEquals(-90f, bolt.pitch, .001f) + assertTrue(bolt.yaw.isFinite() && bolt.roll.isFinite()) + beams.clear() + beams.process(cable(end = Vector3f(10f, 0f, 0f)), 0) + assertEquals(Vector3.Zero, segments(beams, 0).single().position) + beams.process(cable(end = Vector3f.zero), 1) + assertTrue(segments(beams, 1).isEmpty()) + } + + @Test fun `lightning geometry reverses long segments and applies its endpoint correction`() { + val beams = BeamEffects({}) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(100f, 0f, 0f)), 0) + val result = segments(beams, 0) + assertEquals(3, result.size) + assertEquals(0f, result.first().position.x) + assertEquals(45f, result.last().position.x) + assertTrue(result.all { it.yaw == 180f }) + } + + @Test fun `state and geometry are bounded expired slots are reusable and clear releases all beams`() { + val beams = BeamEffects({}, maximumBeams = 1, maximumSegmentsPerBeam = 4) + beams.process(cable(end = Vector3f(4000f, 0f, 0f)), 0) + beams.process(cable(owner = 2), 0) + assertEquals(1, beams.activeCount) + assertEquals(4, segments(beams, 0).size) + beams.process(cable(owner = 2), 200) + assertEquals(1, beams.activeCount) + beams.clear() + assertTrue(segments(beams, 200).isEmpty()) + } + + @Test fun `paused clocks retain geometry and unrelated heatbeams and invalid entities add no state`() { + val beams = BeamEffects({}, random = Random(7)) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(100f, 0f, 0f)), 0) + assertEquals(segments(beams, 50), segments(beams, 50)) + beams.clear() + assertFalse(beams.process(BeamTEMessage(Defines.TE_HEATBEAM, 1, Vector3f.zero, Vector3f.unitX), 0)) + beams.process(cable(owner = -1), 0) + beams.process(LightningTEMessage(1, Defines.MAX_EDICTS, Vector3f.zero, Vector3f.unitX), 0) + assertEquals(0, beams.activeCount) + } +} From b4726419f95975f110f93ab93d0946e9651aa594 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 19:59:42 +0100 Subject: [PATCH 12/42] Render persistent cables and lightning with pooled libGDX models --- .../demoth/cake/stages/ingame/Game3dScreen.kt | 3 + .../ingame/effects/BeamModelRenderer.kt | 62 ++++++++++++ .../ingame/effects/ClientEffectsSystem.kt | 83 ++++------------ .../ingame/effects/EffectAssetCatalog.kt | 2 + .../cake/stages/ingame/effects/README.md | 27 +++++- .../ingame/effects/BeamModelRendererTest.kt | 96 +++++++++++++++++++ 6 files changed, 205 insertions(+), 68 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index 143d958d..d62a8015 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -117,6 +117,9 @@ class Game3dScreen( cameraProvider = { camera }, dynamicLightSystem = dynamicLightSystem, currentTimeMsProvider = { presentationTimeMillis }, + localPlayerEntityIndexProvider = { + gameConfig.playerConfiguration.playerIndex.takeIf { it >= 0 }?.plus(1) + }, ) private val soundMessageHandler = IngameSoundMessageHandler( gameConfig = gameConfig, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt new file mode 100644 index 00000000..0f6f2860 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt @@ -0,0 +1,62 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelBatch +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.Array as GdxArray +import com.badlogic.gdx.utils.Disposable +import com.badlogic.gdx.utils.Pool +import org.demoth.cake.assets.md2.Md2CustomData +import org.demoth.cake.createModelInstance + +/** + * Model-instance submission for beam geometry, using libGDX pools and the existing ModelBatch pass. + * Models belong to the asset catalog. Only instances are pooled here; clearing/disposal never unloads models. + */ +internal class BeamModelRenderer(private val modelProvider: (String) -> Model?) : BeamSegmentSink, Disposable { + private class Instances(val model: Model) { + val active = GdxArray() + val pool = object : Pool(16, 4096) { + override fun newObject(): ModelInstance = createModelInstance(model) + } + } + private val instances = mutableMapOf() + + fun beginFrame() { + instances.values.forEach { + it.pool.freeAll(it.active) + it.active.clear() + } + } + + override fun submit(kind: BeamKind, x: Float, y: Float, z: Float, pitch: Float, yaw: Float, roll: Float) { + val model = modelProvider(kind.modelPath) ?: return // Expansion assets remain optional in base-game profiles. + val group = instances[kind]?.takeIf { it.model === model } ?: Instances(model).also { replacement -> + instances.remove(kind)?.let { it.active.clear(); it.pool.clear() } + instances[kind] = replacement + } + val instance = group.pool.obtain() + instance.transform.idt().rotate(Vector3.Z, yaw).rotate(Vector3.Y, pitch).rotate(Vector3.X, -roll) + .setTranslation(x, y, z) + (instance.userData as? Md2CustomData)?.let { + it.frame1 = 0; it.frame2 = 0; it.interpolation = 0f + if (kind == BeamKind.LIGHTNING) { + it.lightRed = 1f; it.lightGreen = 1f; it.lightBlue = 1f + it.shadeVectorX = 0f; it.shadeVectorY = 0f; it.shadeVectorZ = 0f + } + } + group.active.add(instance) + } + + fun forEachInstance(submit: (ModelInstance) -> Unit) { + instances.values.forEach { group -> group.active.forEach(submit) } + } + + fun render(modelBatch: ModelBatch) = forEachInstance { modelBatch.render(it) } + + override fun dispose() { + instances.values.forEach { it.active.clear(); it.pool.clear() } + instances.clear() + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 4c2e445d..e967ab4a 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -72,6 +72,7 @@ class ClientEffectsSystem( private val cameraProvider: () -> Camera, private val dynamicLightSystem: DynamicLightSystem? = null, private val currentTimeMsProvider: () -> Int = { Globals.curtime }, + private val localPlayerEntityIndexProvider: () -> Int? = { null }, ) : Disposable { private val assetCatalog = EffectAssetCatalog(assetManager) private val spriteRenderer = Sp2Renderer() @@ -86,6 +87,13 @@ class ClientEffectsSystem( private val steamEffects = SteamEffects(particleSystem.runtime, paletteColor = { index -> resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) }) + private val beamModels = BeamModelRenderer { path -> assetCatalog.getModel(path)?.model } + private val beamEffects = BeamEffects(playSound = { request -> + assetCatalog.getSound(request.path)?.let { sound -> + audioSystem.play(SoundPlaybackRequest(sound = sound, entityIndex = request.entityIndex, + channel = request.channel, attenuation = Defines.ATTN_NORM.toFloat())) + } + }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -140,8 +148,8 @@ class ClientEffectsSystem( */ fun processTempEntityMessage(msg: TEMessage) { when (msg) { - is BeamOffsetTEMessage -> handleBeamOffsetMessage(msg) - is BeamTEMessage -> handleBeamMessage(msg) + is BeamOffsetTEMessage, is BeamTEMessage, is LightningTEMessage -> + beamEffects.process(msg, currentTimeMsProvider()) is PointDirectionTEMessage -> handlePointDirectionMessage(msg) is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) @@ -149,7 +157,6 @@ class ClientEffectsSystem( is SteamTEMessage -> steamEffects.process(msg, currentTimeMsProvider()) // These expansion layouts are decoded completely to preserve message boundaries. // Their presentation still needs dedicated effect implementations. - is LightningTEMessage, is FlashlightTEMessage, is ForceWallTEMessage, is WidowBeamOutTEMessage -> Unit @@ -193,6 +200,8 @@ class ClientEffectsSystem( val now = currentTimeMsProvider() particleSystem.update(now) steamEffects.update(now) + beamModels.beginFrame() + beamEffects.submit(now, localPlayerEntityIndexProvider(), cameraProvider().position, beamModels) val iterator = activeEffects.iterator() while (iterator.hasNext()) { val effect = iterator.next() @@ -211,6 +220,7 @@ class ClientEffectsSystem( * use [renderParticles] for the dedicated particle renderer path. */ fun render(modelBatch: ModelBatch) { + beamModels.render(modelBatch) activeEffects.forEach { effect -> effect.render(modelBatch) } @@ -231,9 +241,11 @@ class ClientEffectsSystem( fun debugParticleRenderStats(): ParticleRenderer.Stats = particleSystem.lastRenderStats() - fun debugActiveEffectCount(): Int = activeEffects.size + fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + steamEffects.activeSustainCount override fun dispose() { + beamEffects.clear() + beamModels.dispose() steamEffects.clear() activeEffects.forEach { it.dispose() } activeEffects.clear() @@ -647,30 +659,6 @@ class ClientEffectsSystem( } } - private fun handleBeamMessage(msg: BeamTEMessage) { - if (msg.style == Defines.TE_PARASITE_ATTACK || msg.style == Defines.TE_MEDIC_CABLE_ATTACK) { - spawnSegmentBeam( - modelPath = "models/monsters/parasite/segment/tris.md2", - start = msg.origin.toArray(), - end = msg.destination.toArray(), - offset = null, - durationMs = 200, - ) - } - } - - private fun handleBeamOffsetMessage(msg: BeamOffsetTEMessage) { - if (msg.style == Defines.TE_GRAPPLE_CABLE) { - spawnSegmentBeam( - modelPath = "models/ctf/segment/tris.md2", - start = msg.origin.toArray(), - end = msg.destination.toArray(), - offset = msg.offset.toArray(), - durationMs = 200, - ) - } - } - private fun computeMuzzleOrigin(entityIndex: Int, flashType: Int): Vector3? { val origin = entityManager.getEntityOrigin(entityIndex) ?: return null val angles = entityManager.getEntityAngles(entityIndex) ?: return null @@ -808,45 +796,6 @@ class ClientEffectsSystem( ) } - private fun spawnSegmentBeam( - modelPath: String, - start: FloatArray?, - end: FloatArray?, - offset: FloatArray?, - durationMs: Int, - ) { - val startVector = toVector3(start) ?: return - val endVector = toVector3(end) ?: return - val offsetVector = toVector3(offset) ?: Vector3.Zero - - val beamStart = Vector3(startVector).add(offsetVector) - val beamDirection = Vector3(endVector).sub(beamStart) - val beamLength = beamDirection.len() - if (beamLength <= 0.001f) { - return - } - - beamDirection.scl(1f / beamLength) - val (pitch, yaw) = computeDirectionAngles(beamDirection) ?: return - - val segmentStep = 30f - var travelled = 0f - while (travelled <= beamLength) { - val segmentPosition = Vector3(beamStart).mulAdd(beamDirection, travelled) - spawnAnimatedModelEffect( - modelPath = modelPath, - position = segmentPosition, - firstFrame = 0, - frameCount = 1, - frameDurationMs = durationMs, - pitchDeg = pitch, - yawDeg = yaw, - rollDeg = Globals.rnd.nextInt(360).toFloat(), - ) - travelled += segmentStep - } - } - private fun spawnAnimatedModelEffect( modelPath: String, position: Vector3, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt index a09825ca..c18417f0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt @@ -103,6 +103,7 @@ private val EFFECT_MD2_MODELS = listOf( "models/monsters/parasite/tip/tris.md2", "models/objects/r_explode/tris.md2", "models/objects/r_explode2/tris.md2", + "models/proj/lightning/tris.md2", ) private val EFFECT_SP2_MODELS = listOf( @@ -123,6 +124,7 @@ private val EFFECT_SOUNDS = listOf( "sound/weapons/xpld_wat.wav", "sound/misc/bigtele.wav", "sound/weapons/disrupthit.wav", + "sound/weapons/tesla.wav", "sound/infantry/infatck1.wav", "sound/soldier/solatck1.wav", "sound/soldier/solatck2.wav", diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 9e3a7589..01f9d129 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -79,6 +79,31 @@ intensity/overbright multipliers. Shader sources are `assets/shaders/particle_point.vert/.frag` and `particle_billboard.vert/.frag`, loaded through `AssetManager` and the [shared resolver](../../../assets/README.md). +## Persistent Beams and Lightning + +`BeamEffects` owns cable/grapple and lightning state for the screen. Cable messages +refresh by source entity, so parasite/medic/grapple updates replace their previous +beam. Lightning identifies a source/destination pair, allowing multiple targets +from the same source. Each message refreshes a 200 ms lifetime; expired slots are +reclaimed before admitting new beams. State is bounded to 32 beams and geometry to +128 model segments per beam. + +After the player view is updated, beam geometry is rebuilt from current camera +state. Beams originating from the viewed player follow that camera, with the view +height adjustment and transmitted grapple offset. Other origins retain the recorded +world position. This works for live and recorded views without waiting for another +beam message. Lightning uses its dedicated full-bright model and handles short bolts +from the target end to avoid extending through the Tesla mine. Its roll advances +with presentation time and freezes when that clock pauses. + +`BeamModelRenderer` reuses libGDX `ModelInstance` pools and submits them through the +existing `ModelBatch` pass. It owns no model assets; model loading/unloading remains +with the effect asset catalog. Missing optional expansion models are skipped. Tesla +sound requests use the source entity and weapon channel, resolved through the shared +audio system. Screen teardown clears beam state and instance pools before unloading +catalog assets. Resource-free tests cover state, geometry, sound requests, pooled +instance transforms/reuse and borrowed model ownership using empty libGDX models. + ## Steam and Sustained Emission `SteamEffects` uses the shared libGDX particle runtime for instantaneous steam and @@ -107,7 +132,7 @@ Particle families select colours from `q2palette.bin`. Useful compatibility anch Fallback colours keep effects visible when palette data is unavailable. `TE_BFG_EXPLOSION` uses the translucent `sprites/s_bfg2.sp2` explosion path; `TE_BFG_LASER` remains a procedural `LineBeamEffect` approximation. Parasite/medic -and grapple beams use repeated segment models. Visual parity varies by effect +and grapple beams use pooled segment models rebuilt from persistent beam state. Visual parity varies by effect family; unsupported styles inside recognised TE message classes can fall through silently. The decoded expansion message classes with no presentation implementation are currently ignored explicitly. diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt new file mode 100644 index 00000000..4ced10af --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt @@ -0,0 +1,96 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import org.demoth.cake.assets.md2.Md2CustomData +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +/** Uses empty libGDX models and matrices, without meshes, textures, GL, windows or installed content. */ +class BeamModelRendererTest { + private fun instances(renderer: BeamModelRenderer): List = buildList { renderer.forEachInstance { add(it) } } + + @Test fun `frame rebuilds reuse instances instead of accumulating segments`() { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(BeamKind.CABLE, 1f, 2f, 3f, 0f, 90f, 0f) + renderer.submit(BeamKind.CABLE, 4f, 5f, 6f, 0f, 0f, 0f) + val initial = instances(renderer).toSet() + assertEquals(2, initial.size) + repeat(100) { + renderer.beginFrame() + renderer.submit(BeamKind.CABLE, 7f, 8f, 9f, 0f, 90f, 0f) + renderer.submit(BeamKind.CABLE, 10f, 11f, 12f, 0f, 0f, 0f) + assertEquals(initial, instances(renderer).toSet()) + } + val first = instances(renderer).first() + assertEquals(Vector3(7f, 8f, 9f), first.transform.getTranslation(Vector3())) + val forward = Vector3.X.cpy().rot(first.transform) + assertTrue(forward.epsilonEquals(Vector3.Y, .0001f)) + renderer.beginFrame() + assertTrue(instances(renderer).isEmpty()) + } finally { renderer.dispose(); model.dispose() } + } + + @Test fun `missing optional assets are skipped and later model replacements discard stale instances`() { + val firstModel = Model() + val secondModel = Model() + var available: Model? = null + val renderer = BeamModelRenderer { available } + try { + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertTrue(instances(renderer).isEmpty()) + available = firstModel + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + val original = instances(renderer).single() + renderer.beginFrame() + available = secondModel + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertNotSame(original, instances(renderer).single()) + assertSame(secondModel, instances(renderer).single().model) + } finally { renderer.dispose(); firstModel.dispose(); secondModel.dispose() } + } + + @Test fun `lightning restores full-bright frame state when a model instance is reused`() { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + val instance = instances(renderer).single() + instance.userData = Md2CustomData.empty().apply { + frame1 = 9; frame2 = 10; interpolation = .5f + lightRed = 0f; lightGreen = 0f; lightBlue = 0f + shadeVectorX = 1f; shadeVectorY = 1f; shadeVectorZ = 1f + } + renderer.beginFrame() + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertSame(instance, instances(renderer).single()) + val data = instance.userData as Md2CustomData + assertEquals(0, data.frame1); assertEquals(0, data.frame2); assertEquals(0f, data.interpolation) + assertEquals(1f, data.lightRed); assertEquals(1f, data.lightGreen); assertEquals(1f, data.lightBlue) + assertEquals(0f, data.shadeVectorX); assertEquals(0f, data.shadeVectorY); assertEquals(0f, data.shadeVectorZ) + } finally { renderer.dispose(); model.dispose() } + } + + @Test fun `disposal releases instances and leaves shared model lifetime to the asset owner`() { + var disposed = false + val model = object : Model() { + override fun dispose() { disposed = true; super.dispose() } + } + val renderer = BeamModelRenderer { model } + renderer.submit(BeamKind.GRAPPLE, 0f, 0f, 0f, 0f, 0f, 0f) + renderer.dispose() + assertTrue(instances(renderer).isEmpty()) + assertFalse(disposed) + model.dispose() + assertTrue(disposed) + } + + companion object { + @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } + } +} From bf1638372e4884b8dd8d8d21a4f25e9e33417003 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:15:08 +0100 Subject: [PATCH 13/42] Add camera-attached player and recorded monster heatbeam geometry --- .../cake/stages/ingame/effects/BeamEffects.kt | 6 +- .../ingame/effects/BeamModelRenderer.kt | 4 +- .../stages/ingame/effects/HeatbeamEffects.kt | 89 +++++++++++++++ .../ingame/effects/HeatbeamEffectsTest.kt | 103 ++++++++++++++++++ 4 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt index 1553ebfa..6f313b93 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt @@ -8,10 +8,12 @@ import jake2.qcommon.network.messages.server.* import kotlin.math.ceil import kotlin.random.Random -internal enum class BeamKind(val modelPath: String, val segmentLength: Float) { +internal enum class BeamKind(val modelPath: String, val segmentLength: Float, val frame: Int = 0, val fullBright: Boolean = false) { CABLE("models/monsters/parasite/segment/tris.md2", 30f), GRAPPLE("models/ctf/segment/tris.md2", 30f), - LIGHTNING("models/proj/lightning/tris.md2", 35f), + LIGHTNING("models/proj/lightning/tris.md2", 35f, fullBright = true), + HEATBEAM_PLAYER("models/proj/beam/tris.md2", 32f, frame = 1, fullBright = true), + HEATBEAM_MONSTER("models/proj/beam/tris.md2", 32f, frame = 2, fullBright = true), } internal data class BeamSoundRequest(val path: String, val entityIndex: Int, val channel: Int) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt index 0f6f2860..01d1ac55 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt @@ -40,8 +40,8 @@ internal class BeamModelRenderer(private val modelProvider: (String) -> Model?) instance.transform.idt().rotate(Vector3.Z, yaw).rotate(Vector3.Y, pitch).rotate(Vector3.X, -roll) .setTranslation(x, y, z) (instance.userData as? Md2CustomData)?.let { - it.frame1 = 0; it.frame2 = 0; it.interpolation = 0f - if (kind == BeamKind.LIGHTNING) { + it.frame1 = kind.frame; it.frame2 = kind.frame; it.interpolation = 0f + if (kind.fullBright) { it.lightRed = 1f; it.lightGreen = 1f; it.lightBlue = 1f it.shadeVectorX = 0f; it.shadeVectorY = 0f; it.shadeVectorZ = 0f } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt new file mode 100644 index 00000000..3c9e801b --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt @@ -0,0 +1,89 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Camera +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.BeamTEMessage +import kotlin.math.ceil +import kotlin.math.sqrt + +/** Consumes borrowed per-frame vectors synchronously; implementations must copy anything they retain. */ +internal fun interface HeatbeamParticleSink { + fun emit(local: Boolean, origin: Vector3, direction: Vector3, right: Vector3, up: Vector3, length: Float, nowMs: Int) +} + +/** + * Owned heatbeam state (CL_ParseHeatBeam / CL_AddHeatBeams). Local beams follow the presented + * camera and gun offset; all other beams retain their recorded endpoints. Both live and demo + * views supply the same context. The existing world/view-weapon camera also projects these models. + */ +internal class HeatbeamEffects( + private val particles: HeatbeamParticleSink, + private val maximumBeams: Int = 32, + private val maximumSegmentsPerBeam: Int = 128, +) { + init { require(maximumBeams > 0 && maximumSegmentsPerBeam > 0) } + private data class Beam(val message: BeamTEMessage, val expiresAt: Long, var lastParticlesAt: Int?) + private val beams = LinkedHashMap() + private val origin = Vector3() + private val direction = Vector3() + private val right = Vector3() + private val up = Vector3() + val activeCount: Int get() = beams.size + + fun process(message: BeamTEMessage, nowMs: Int): Boolean { + if (message.style != Defines.TE_HEATBEAM && message.style != Defines.TE_MONSTER_HEATBEAM) return false + if (message.ownerIndex !in 0 until Defines.MAX_EDICTS) return true + expire(nowMs) + if (message.ownerIndex !in beams && beams.size >= maximumBeams) return true + beams[message.ownerIndex] = Beam(message, nowMs.toLong() + 200, beams[message.ownerIndex]?.lastParticlesAt) + return true + } + + fun submit(nowMs: Int, localPlayerEntity: Int?, camera: Camera, gunOffset: Vector3, hand: Int, sink: BeamSegmentSink) { + expire(nowMs) + for ((owner, beam) in beams) { + val message = beam.message + val local = owner == localPlayerEntity && message.style == Defines.TE_HEATBEAM + if (local) { + direction.set(camera.direction).nor() + right.set(direction).crs(camera.up).nor() + up.set(right).crs(direction).nor() + val side = when (hand) { 1 -> -1f; 2 -> 0f; else -> 1f } + // CL_ParseHeatBeam's player offset: right 2, forward 7, up -3; centered weapons sit one unit lower. + origin.set(camera.position).add(gunOffset).mulAdd(right, 2f * side).mulAdd(direction, 7f) + .mulAdd(up, if (hand == 2) -4f else -3f) + } else { + origin.set(message.origin.x, message.origin.y, message.origin.z) + } + val end = message.destination + val dx = end.x - origin.x; val dy = end.y - origin.y; val dz = end.z - origin.z + val length = sqrt(dx * dx + dy * dy + dz * dz) + if (length < .001f) continue + if (!local) { + direction.set(dx, dy, dz).scl(1f / length) + right.set(direction).crs(if (kotlin.math.abs(direction.z) < .9f) Vector3.Z else Vector3.X).nor() + up.set(right).crs(direction).nor() + } + val kind = if (local) BeamKind.HEATBEAM_PLAYER else BeamKind.HEATBEAM_MONSTER + val pitch = MathUtils.atan2(direction.z, sqrt(direction.x * direction.x + direction.y * direction.y)) * MathUtils.radiansToDegrees + val yaw = MathUtils.atan2(direction.y, direction.x) * MathUtils.radiansToDegrees + 180f + val count = ceil(length / kind.segmentLength).toInt().coerceIn(1, maximumSegmentsPerBeam) + val step = if (count > 1) (length - kind.segmentLength) / (count - 1) else 0f + for (index in 0 until count) { + val distance = index * step + sink.submit(kind, origin.x + direction.x * distance, origin.y + direction.y * distance, + origin.z + direction.z * distance, pitch, yaw, (nowMs % 360).toFloat()) + } + // Short overlapping pulses give consistent density without emitting on every render or while paused. + if (beam.lastParticlesAt == null || nowMs.toLong() - beam.lastParticlesAt!! >= 50) { + beam.lastParticlesAt = nowMs + particles.emit(local, origin, direction, right, up, length, nowMs) + } + } + } + + fun clear() = beams.clear() + private fun expire(nowMs: Int) = beams.values.removeAll { nowMs.toLong() >= it.expiresAt } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt new file mode 100644 index 00000000..17ad6105 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt @@ -0,0 +1,103 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.PerspectiveCamera +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.BeamTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class HeatbeamEffectsTest { + private data class Segment(val kind: BeamKind, val origin: Vector3, val pitch: Float, val yaw: Float, val roll: Float) + private fun camera(fov: Float = 90f) = PerspectiveCamera(fov, 800f, 600f).apply { + direction.set(1f, 0f, 0f); up.set(0f, 0f, 1f); update() + } + private fun message(owner: Int = 1, monster: Boolean = false, end: Vector3f = Vector3f(100f, 0f, 0f)) = + BeamTEMessage(if (monster) Defines.TE_MONSTER_HEATBEAM else Defines.TE_HEATBEAM, + owner, Vector3f.zero, end) + private fun segments(effect: HeatbeamEffects, now: Int, local: Int? = null, view: PerspectiveCamera = camera(), + offset: Vector3 = Vector3.Zero, hand: Int = 0): List = buildList { + effect.submit(now, local, view, offset, hand, BeamSegmentSink { kind, x, y, z, pitch, yaw, roll -> + add(Segment(kind, Vector3(x, y, z), pitch, yaw, roll)) + }) + } + private fun effect() = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> }) + + @Test fun `local muzzle follows camera gun offsets and handedness without needing another message`() { + val effect = effect() + effect.process(message(), 0) + val view = camera().apply { position.set(10f, 20f, 30f) } + val gun = Vector3(1f, 2f, 3f) + assertEquals(Vector3(18f, 20f, 30f), segments(effect, 0, 1, view, gun).first().origin) + assertEquals(Vector3(18f, 24f, 30f), segments(effect, 0, 1, view, gun, 1).first().origin) + assertEquals(Vector3(18f, 22f, 29f), segments(effect, 0, 1, view, gun, 2).first().origin) + view.position.add(10f, 0f, 0f) + assertEquals(Vector3(28f, 20f, 30f), segments(effect, 50, 1, view, gun).first().origin) + view.direction.set(0f, 1f, 0f) + assertEquals(Vector3(23f, 29f, 30f), segments(effect, 100, 1, view, gun).first().origin) + } + + @Test fun `monster and remote-player beams use recorded origins and the remote model frame`() { + val effect = effect() + effect.process(message(monster = true), 0) + val monster = segments(effect, 0, 1).first() + assertEquals(Vector3.Zero, monster.origin) + assertEquals(BeamKind.HEATBEAM_MONSTER, monster.kind) + effect.process(message(owner = 2), 0) + assertTrue(segments(effect, 0, 1).all { it.kind == BeamKind.HEATBEAM_MONSTER }) + } + + @Test fun `muzzle uses the active camera projection across field of view changes`() { + val effect = effect() + effect.process(message(), 0) + val projections = listOf(60f, 90f, 120f).map { fov -> + val view = camera(fov) + val first = segments(effect, 0, 1, view).first() + assertEquals(BeamKind.HEATBEAM_PLAYER, first.kind) + assertEquals(Vector3(7f, -2f, -3f), first.origin) + view.project(first.origin.cpy(), 0f, 0f, 800f, 600f).x + } + assertTrue(projections.all { it > 400f }) + assertTrue(projections[0] > projections[1] && projections[1] > projections[2]) + } + + @Test fun `refresh and pause keep one beam and rate-limit particle pulses`() { + var pulses = 0 + val effect = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> pulses++ }) + effect.process(message(), 0) + val initial = segments(effect, 0) + repeat(30) { assertEquals(initial, segments(effect, 0)) } + assertEquals(1, pulses) + effect.process(message(end = Vector3f(200f, 0f, 0f)), 0) + segments(effect, 0) + assertEquals(1, pulses) + segments(effect, 50) + assertEquals(2, pulses) + effect.process(message(), 150) + assertEquals(1, effect.activeCount) + assertTrue(segments(effect, 200).isNotEmpty()) + assertTrue(segments(effect, 350).isEmpty()) + } + + @Test fun `short vertical and zero length beams stay finite and state and geometry stay bounded`() { + val effect = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> }, maximumBeams = 1, maximumSegmentsPerBeam = 4) + effect.process(message(end = Vector3f(0f, 0f, 10f)), 0) + val short = segments(effect, 0).single() + assertEquals(90f, short.pitch, .001f) + assertTrue(short.yaw.isFinite() && short.roll.isFinite()) + effect.process(message(end = Vector3f.zero), 0) + assertTrue(segments(effect, 0).isEmpty()) + effect.process(message(end = Vector3f(4000f, 0f, 0f)), 0) + effect.process(message(owner = 2), 0) + assertEquals(1, effect.activeCount) + assertEquals(4, segments(effect, 0).size) + effect.clear() + assertEquals(0, effect.activeCount) + assertTrue(segments(effect, 0).isEmpty()) + } + + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } +} From 491c6bcbb4679a2120341537538152cf2e0c289e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:18:50 +0100 Subject: [PATCH 14/42] Render heatbeam segments rings and monster plasma with libGDX --- cake/README.md | 4 +- .../demoth/cake/stages/ingame/Game3dScreen.kt | 14 ++++ .../ingame/effects/ClientEffectsSystem.kt | 21 ++++- .../ingame/effects/EffectAssetCatalog.kt | 1 + .../ingame/effects/HeatbeamParticles.kt | 44 +++++++++++ .../cake/stages/ingame/effects/README.md | 23 ++++++ .../ingame/effects/BeamModelRendererTest.kt | 20 +++++ .../ingame/effects/HeatbeamEffectsTest.kt | 24 ++++++ .../ingame/effects/HeatbeamParticlesTest.kt | 79 +++++++++++++++++++ 9 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt diff --git a/cake/README.md b/cake/README.md index 22675eaa..51e2a077 100644 --- a/cake/README.md +++ b/cake/README.md @@ -173,8 +173,8 @@ models, and sounds, then open the client console: EOF and playback errors return to the console. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look do not control playback. Opening the console leaves playback running. -Some expansion temporary effects, including heatbeam geometry, are decoded but -not yet rendered. Steam and heatbeam impact particles are supported. +Some expansion temporary effects remain unrendered. Steam, lightning, and player +and monster heatbeams are supported. Protocol-26 demos shipped with older Quake II releases, extended/rerelease protocols, and cinematic recordings are unsupported. For a compatible test file, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index d62a8015..a6483773 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -27,6 +27,8 @@ import jake2.qcommon.CM import jake2.qcommon.Com import jake2.qcommon.Defines import jake2.qcommon.Globals +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.messages.client.MoveMessage import jake2.qcommon.network.messages.server.* @@ -110,6 +112,8 @@ class Game3dScreen( private val hudOverlayRenderer by lazy { HudOverlayRenderer(spriteBatch, gameConfig, uiViewport) } private val entityManager = ClientEntityManager() + private val heatbeamGunOffset = Vector3() + private val handedness = Cvar.getInstance().find("hand") private val effectsSystem = ClientEffectsSystem( assetManager = assetManager, entityManager = entityManager, @@ -120,6 +124,16 @@ class Game3dScreen( localPlayerEntityIndexProvider = { gameConfig.playerConfiguration.playerIndex.takeIf { it >= 0 }?.plus(1) }, + gunOffsetProvider = { + val current = entityManager.currentFrame.playerstate.gunoffset + val previous = entityManager.previousFrame?.playerstate?.gunoffset ?: current + heatbeamGunOffset.set( + previous[0] + (current[0] - previous[0]) * lerpFrac, + previous[1] + (current[1] - previous[1]) * lerpFrac, + previous[2] + (current[2] - previous[2]) * lerpFrac, + ) + }, + handednessProvider = { handedness.typedValue }, ) private val soundMessageHandler = IngameSoundMessageHandler( gameConfig = gameConfig, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index e967ab4a..06b9e3b1 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -73,6 +73,8 @@ class ClientEffectsSystem( private val dynamicLightSystem: DynamicLightSystem? = null, private val currentTimeMsProvider: () -> Int = { Globals.curtime }, private val localPlayerEntityIndexProvider: () -> Int? = { null }, + private val gunOffsetProvider: () -> Vector3 = { Vector3.Zero }, + private val handednessProvider: () -> Int = { 0 }, ) : Disposable { private val assetCatalog = EffectAssetCatalog(assetManager) private val spriteRenderer = Sp2Renderer() @@ -94,6 +96,9 @@ class ClientEffectsSystem( channel = request.channel, attenuation = Defines.ATTN_NORM.toFloat())) } }) + private val heatbeamEffects = HeatbeamEffects(HeatbeamParticles(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = HEATBEAM_PARTICLE_FALLBACK) + })) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -148,8 +153,11 @@ class ClientEffectsSystem( */ fun processTempEntityMessage(msg: TEMessage) { when (msg) { - is BeamOffsetTEMessage, is BeamTEMessage, is LightningTEMessage -> - beamEffects.process(msg, currentTimeMsProvider()) + is BeamTEMessage -> { + val now = currentTimeMsProvider() + if (!heatbeamEffects.process(msg, now)) beamEffects.process(msg, now) + } + is BeamOffsetTEMessage, is LightningTEMessage -> beamEffects.process(msg, currentTimeMsProvider()) is PointDirectionTEMessage -> handlePointDirectionMessage(msg) is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) @@ -201,7 +209,10 @@ class ClientEffectsSystem( particleSystem.update(now) steamEffects.update(now) beamModels.beginFrame() - beamEffects.submit(now, localPlayerEntityIndexProvider(), cameraProvider().position, beamModels) + val camera = cameraProvider() + val localPlayer = localPlayerEntityIndexProvider() + beamEffects.submit(now, localPlayer, camera.position, beamModels) + heatbeamEffects.submit(now, localPlayer, camera, gunOffsetProvider(), handednessProvider(), beamModels) val iterator = activeEffects.iterator() while (iterator.hasNext()) { val effect = iterator.next() @@ -241,9 +252,10 @@ class ClientEffectsSystem( fun debugParticleRenderStats(): ParticleRenderer.Stats = particleSystem.lastRenderStats() - fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + steamEffects.activeSustainCount + fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount override fun dispose() { + heatbeamEffects.clear() beamEffects.clear() beamModels.dispose() steamEffects.clear() @@ -1626,6 +1638,7 @@ private const val SPARK_PARTICLE_BASE_COLOR = 0xE0 private const val SCREEN_SPARK_PARTICLE_BASE_COLOR = 0xD0 private const val SHIELD_SPARK_PARTICLE_BASE_COLOR = 0xB0 private const val SHOTGUN_PARTICLE_BASE_COLOR = 0x00 +private val HEATBEAM_PARTICLE_FALLBACK = Color(1f, .7f, .2f, 1f) private val STEAM_PARTICLE_FALLBACK = Color(.75f, .75f, .75f, 1f) private const val ELECTRIC_SPARK_PARTICLE_BASE_COLOR = 0x75 private const val CHAINFIST_SMOKE_PARTICLE_BASE_COLOR = 0x00 diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt index c18417f0..66602374 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt @@ -104,6 +104,7 @@ private val EFFECT_MD2_MODELS = listOf( "models/objects/r_explode/tris.md2", "models/objects/r_explode2/tris.md2", "models/proj/lightning/tris.md2", + "models/proj/beam/tris.md2", ) private val EFFECT_SP2_MODELS = listOf( diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt new file mode 100644 index 00000000..bf7e5ff9 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import kotlin.random.Random + +/** Brief libGDX particle pulses for CL_Heatbeam rings and CL_MonsterPlasma_Shell. */ +internal class HeatbeamParticles( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, +) : HeatbeamParticleSink { + private val position = Vector3() + private val radial = Vector3() + + override fun emit(local: Boolean, origin: Vector3, direction: Vector3, right: Vector3, up: Vector3, length: Float, nowMs: Int) { + if (local) { + val phase = (nowMs % 1000) * .096f % 32f + repeat(5) { ring -> + val distance = 8f + phase + ring * 32f + if (distance > length || distance > 160f) return + val radius = 1.5f * (distance / 10f).coerceAtMost(1f) + repeat(20) { point -> + val angle = point * 18f + position.set(origin).mulAdd(direction, distance).mulAdd(right, MathUtils.cosDeg(angle) * radius) + .mulAdd(up, MathUtils.sinDeg(angle) * radius) + if (!emitParticle(position, 223 - random.nextInt(8), .5f, nowMs)) return + } + } + } else { + repeat(40) { + radial.set(random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f) + if (radial.isZero) radial.set(Vector3.Z) + position.set(origin).mulAdd(radial.nor(), 10f) + if (!emitParticle(position, 0xE0, 1f, nowMs)) return + } + } + } + + private fun emitParticle(position: Vector3, color: Int, alpha: Float, nowMs: Int): Boolean = + particles.emit(position, Vector3.Zero, Vector3.Zero, paletteColor(color), size = .5f, + lifetimeMillis = 75, nowMs = nowMs, startAlpha = alpha) +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 01f9d129..50838728 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -104,6 +104,29 @@ audio system. Screen teardown clears beam state and instance pools before unload catalog assets. Resource-free tests cover state, geometry, sound requests, pooled instance transforms/reuse and borrowed model ownership using empty libGDX models. +## Player and Monster Heatbeams + +`HeatbeamEffects` owns up to 32 heatbeams, refreshed by source entity with a 200 ms +lifetime. Local player beams follow the presented camera direction and interpolated +replicated gun offset. The `hand` setting shifts the muzzle right, left or to the +center; model/projection mirroring is not used. Remote player and monster beams +retain their recorded world endpoints. All views, including demos, supply the same +presentation context. + +Heatbeam segments reuse the shared model-instance pools with the optional +`models/proj/beam/tris.md2` asset. Player and remote beams select frames 1 and 2, +respectively, use full-bright lighting and roll with presentation time. Segment +counts are capped at 128 per beam. Models share the world/view-weapon camera +projection, so they require no separate weapon-FOV correction. + +`HeatbeamParticles` emits short pulses through the libGDX particle runtime: up to +five moving rings near the local muzzle, or a plasma shell around a remote origin. +Pulses are limited to one per source every 50 ms, live for 75 ms and obey the shared +particle budget. Refreshing messages or rendering repeatedly at a paused timestamp +does not add duplicate pulses. Screen teardown clears heatbeam state, model pools +and the shared particle runtime. Synthetic-camera and particle tests cover these +paths without game assets or a window. + ## Steam and Sustained Emission `SteamEffects` uses the shared libGDX particle runtime for instantaneous steam and diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt index 4ced10af..224dd609 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt @@ -90,6 +90,26 @@ class BeamModelRendererTest { assertTrue(disposed) } + @Test fun `heatbeam model instances select their player or remote frame and full-bright lighting`() { + for ((kind, frame) in listOf(BeamKind.HEATBEAM_PLAYER to 1, BeamKind.HEATBEAM_MONSTER to 2)) { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(kind, 0f, 0f, 0f, 0f, 0f, 0f) + val instance = instances(renderer).single() + instance.userData = Md2CustomData.empty().apply { lightRed = 0f; frame1 = 99 } + renderer.beginFrame() + renderer.submit(kind, 0f, 0f, 0f, 0f, 0f, 30f) + val data = instance.userData as Md2CustomData + assertEquals(frame, data.frame1) + assertEquals(frame, data.frame2) + assertEquals(0f, data.interpolation) + assertEquals(1f, data.lightRed) + assertSame(instance, instances(renderer).single()) + } finally { renderer.dispose(); model.dispose() } + } + } + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt index 17ad6105..2fb9d359 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt @@ -99,5 +99,29 @@ class HeatbeamEffectsTest { assertTrue(segments(effect, 0).isEmpty()) } + @Test fun `the real particle runtime stays bounded during refresh pause and teardown`() { + val runtime = GdxParticleRuntime({ 1000 }) + try { + val effect = HeatbeamEffects(HeatbeamParticles(runtime, { com.badlogic.gdx.graphics.Color.WHITE })) + effect.process(message(), 0) + val view = camera() + segments(effect, 0, 1, view) + val firstCount = runtime.liveCount + assertTrue(firstCount in 1..100) + repeat(100) { segments(effect, 0, 1, view) } + assertEquals(firstCount, runtime.liveCount) + segments(effect, 50, 1, view) + assertTrue(runtime.liveCount <= 200) + effect.process(message(), 50) + repeat(100) { segments(effect, 50, 1, view) } + assertTrue(runtime.liveCount <= 200) + effect.clear() + segments(effect, 150, 1, view) + runtime.update(150) + assertEquals(0, effect.activeCount) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt new file mode 100644 index 00000000..377057cc --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.math.sqrt +import kotlin.random.Random + +class HeatbeamParticlesTest { + private data class Sample(val position: Vector3, val alpha: Float) + private class Fixture(private val budget: Int = 1000) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val paletteIndices = mutableListOf() + val effect = HeatbeamParticles(runtime, { index -> paletteIndices += index; Color.WHITE }, Random(7)) + fun emit(local: Boolean, length: Float = 500f, now: Int = 0) { + effect.emit(local, Vector3.Zero, Vector3.X, Vector3(0f, -1f, 0f), Vector3.Z, length, now) + } + fun samples(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, alpha, _ -> add(Sample(Vector3(x, y, z), alpha)) }) + } + override fun close() = runtime.dispose() + } + + @Test fun `player rings stay near the beam origin use a narrow radius and fade as short pulses`() { + Fixture().use { fixture -> + fixture.emit(local = true) + val particles = fixture.samples(0) + assertEquals(100, particles.size) + assertTrue(fixture.paletteIndices.all { it in 216..223 }) + assertTrue(particles.all { it.position.x in 8f..160f && it.alpha == .5f }) + assertEquals(5, particles.map { it.position.x }.toSet().size) + assertTrue(particles.all { sqrt(it.position.y * it.position.y + it.position.z * it.position.z) <= 1.51f }) + assertTrue(fixture.samples(50).all { it.alpha in 0f..0.5f }) + assertTrue(fixture.samples(75).isEmpty()) + } + } + + @Test fun `rings respect beam range and advance along the beam with presentation time`() { + Fixture().use { fixture -> + fixture.emit(local = true, length = 5f) + assertEquals(0, fixture.runtime.liveCount) + fixture.emit(local = true, length = 25f) + assertEquals(20, fixture.runtime.liveCount) + assertEquals(8f, fixture.samples(0).first().position.x) + fixture.runtime.clear() + fixture.emit(local = true, length = 25f, now = 50) + assertEquals(12.8f, fixture.samples(50).first().position.x, .001f) + assertTrue(fixture.samples(50).all { it.position.x <= 25f }) + } + } + + @Test fun `monster plasma forms a bounded shell instead of a forward trail`() { + Fixture().use { fixture -> + fixture.emit(local = false) + val particles = fixture.samples(0) + assertEquals(40, particles.size) + assertTrue(particles.all { kotlin.math.abs(it.position.len() - 10f) < .001f && it.alpha == 1f }) + assertTrue(fixture.paletteIndices.all { it == 0xE0 }) + assertEquals(particles.map { it.position }, fixture.samples(50).map { it.position }) + assertTrue(fixture.samples(75).isEmpty()) + } + } + + @Test fun `particle budgets cap rings and shells and disable emission at zero`() { + for (local in listOf(true, false)) { + Fixture(budget = 3).use { fixture -> + fixture.emit(local) + assertEquals(3, fixture.runtime.liveCount) + fixture.emit(local) + assertEquals(3, fixture.runtime.liveCount) + } + Fixture(budget = 0).use { fixture -> + fixture.emit(local) + assertEquals(0, fixture.runtime.liveCount) + } + } + } +} From 5549947b02c38a4ff02e8ed340b8305a367db300 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:27:36 +0100 Subject: [PATCH 15/42] Present flashlight force-wall and dense bubble-trail effects --- .../ingame/effects/ClientEffectsSystem.kt | 11 ++- .../stages/ingame/effects/ExpansionEffects.kt | 79 +++++++++++++++ .../ingame/effects/ExpansionEffectsTest.kt | 96 +++++++++++++++++++ 3 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 06b9e3b1..e14a2851 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -99,6 +99,10 @@ class ClientEffectsSystem( private val heatbeamEffects = HeatbeamEffects(HeatbeamParticles(particleSystem.runtime, paletteColor = { index -> resolvePaletteColor(index, fallback = HEATBEAM_PARTICLE_FALLBACK) })) + private val expansionEffects = ExpansionEffects(particleSystem.runtime, + paletteColor = { index -> resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) }, + lights = dynamicLightSystem, + playBubbleSound = { position -> playEffectSound("sound/weapons/lashit.wav", toVector3(position)) }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -163,10 +167,8 @@ class ClientEffectsSystem( is SplashTEMessage -> handleSplashMessage(msg) is PointTEMessage -> handlePointMessage(msg) is SteamTEMessage -> steamEffects.process(msg, currentTimeMsProvider()) - // These expansion layouts are decoded completely to preserve message boundaries. - // Their presentation still needs dedicated effect implementations. - is FlashlightTEMessage, - is ForceWallTEMessage, + // Flashlight and force-wall messages feed the shared light and particle runtimes. + is FlashlightTEMessage, is ForceWallTEMessage -> expansionEffects.process(msg, currentTimeMsProvider()) is WidowBeamOutTEMessage -> Unit } } @@ -413,6 +415,7 @@ class ClientEffectsSystem( } private fun handleTrailMessage(msg: TrailTEMessage) { + if (expansionEffects.process(msg, currentTimeMsProvider())) return val start = toVector3(msg.position) val end = toVector3(msg.destination) when (msg.style) { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt new file mode 100644 index 00000000..845c1ed4 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.stages.ingame.DynamicLightSystem +import kotlin.math.ceil +import kotlin.random.Random + +/** One-shot expansion effects: CL_Flashlight, CL_ForceWall and CL_BubbleTrail2. */ +internal class ExpansionEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val lights: DynamicLightSystem?, + private val playBubbleSound: (Vector3f) -> Unit, + private val random: Random = Random.Default, + private val maximumTrailPoints: Int = 1024, +) { + init { require(maximumTrailPoints > 0) } + private val origin = Vector3() + private val direction = Vector3() + private val position = Vector3() + private val velocity = Vector3() + + fun process(message: TEMessage, nowMs: Int): Boolean { + when (message) { + is FlashlightTEMessage -> { + if (message.entityIndex in 0 until Defines.MAX_EDICTS) { + lights?.spawnTransientLight(message.entityIndex, vector(message.position), 400f, 1f, 1f, 1f, + lifetimeMs = 100, currentTimeMs = nowMs) + } + } + is ForceWallTEMessage -> emitTrail(message.origin, message.destination, wallColor = message.color, nowMs = nowMs) + is TrailTEMessage -> { + if (message.style != Defines.TE_BUBBLETRAIL2) return false + emitTrail(message.position, message.destination, wallColor = null, nowMs = nowMs) + playBubbleSound(message.position) + } + else -> return false + } + return true + } + + private fun emitTrail(start: Vector3f, end: Vector3f, wallColor: Int?, nowMs: Int) { + origin.set(start.x, start.y, start.z) + direction.set(end.x, end.y, end.z).sub(origin) + val length = direction.len() + if (length < .001f) return + direction.scl(1f / length) + val spacing = if (wallColor != null) 4f else 8f + val count = ceil(length / spacing).toInt().coerceIn(1, maximumTrailPoints) + val step = length / count + repeat(count) { index -> + if (wallColor != null && random.nextFloat() < .3f) return@repeat + val jitter = if (wallColor != null) 3f else 2f + position.set(origin).mulAdd(direction, index * step) + .add(signedRandom() * jitter, signedRandom() * jitter, signedRandom() * jitter) + val color: Int + val lifetime: Int + if (wallColor != null) { + velocity.set(0f, 0f, -40f + signedRandom() * 10f) + color = wallColor + lifetime = random.nextInt(3000, 3501) + } else { + position.z -= 4f + velocity.set(signedRandom() * 10f, signedRandom() * 10f, 20f + signedRandom() * 10f) + color = 4 + random.nextInt(8) + lifetime = random.nextInt(1000, 1101) + } + if (!particles.emit(position, velocity, Vector3.Zero, paletteColor(color), + size = if (wallColor != null) .6f else .4f, lifetimeMillis = lifetime, nowMs = nowMs)) return + } + } + + private fun signedRandom(): Float = random.nextFloat() * 2f - 1f + private fun vector(value: Vector3f) = position.set(value.x, value.y, value.z) +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt new file mode 100644 index 00000000..353f9e9d --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt @@ -0,0 +1,96 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ExpansionEffectsTest { + private class Fixture(budget: Int = 1000, maximumTrailPoints: Int = 1024) : AutoCloseable { + val particles = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val sounds = mutableListOf() + val lights = DynamicLightSystem() + val effects = ExpansionEffects(particles, { index -> colors += index; Color.WHITE }, lights, + sounds::add, Random(7), maximumTrailPoints) + fun positions(nowMs: Int): List = buildList { + particles.submit(nowMs, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = particles.dispose() + } + + @Test fun `flashlight refreshes the source keyed light and expires through the shared lighting runtime`() { + RenderTuningCvars.register() + Fixture().use { fixture -> + fixture.effects.process(FlashlightTEMessage(Vector3f(1f, 2f, 3f), 7), 0) + fixture.lights.beginFrame(0, 0f) + val first = fixture.lights.visibleLightsForShader().single() + assertEquals(Vector3(1f, 2f, 3f), first.origin) + assertEquals(400f, first.radius) + assertEquals(1f, first.red); assertEquals(1f, first.green); assertEquals(1f, first.blue) + fixture.effects.process(FlashlightTEMessage(Vector3f(4f, 5f, 6f), 7), 50) + fixture.lights.beginFrame(100, .05f) + assertEquals(Vector3(4f, 5f, 6f), fixture.lights.visibleLightsForShader().single().origin) + fixture.lights.beginFrame(200, .1f) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + fixture.effects.process(FlashlightTEMessage(Vector3f.zero, -1), 200) + fixture.lights.beginFrame(200, 0f) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } + + @Test fun `force wall spreads its palette particles across the segment and lets them fall`() { + Fixture().use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f(80f, 0f, 0f), 37), 0) + val first = fixture.positions(0) + assertTrue(first.size in 1..20) + assertTrue(first.all { it.x in -3f..83f && it.y in -3f..3f && it.z in -3f..3f }) + assertTrue(fixture.colors.all { it == 37 }) + val later = fixture.positions(250) + for ((before, after) in first.zip(later)) { + assertEquals(before.x, after.x) + assertEquals(before.y, after.y) + assertTrue(after.z - before.z in -12.51f..-7.49f) + } + assertTrue(fixture.positions(3501).isEmpty()) + } + } + + @Test fun `bubble trail two is dense rising palette colored and requests its sound at the start`() { + Fixture().use { fixture -> + val message = TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(32f, 0f, 0f)) + assertTrue(fixture.effects.process(message, 0)) + val first = fixture.positions(0) + assertEquals(4, first.size) + assertTrue(first.all { it.y in -2f..2f && it.z in -6f..-2f }) + assertTrue(fixture.colors.all { it in 4..11 }) + assertEquals(listOf(Vector3f.zero), fixture.sounds) + for ((before, after) in first.zip(fixture.positions(100))) assertTrue(after.z > before.z) + assertTrue(fixture.positions(1101).isEmpty()) + assertFalse(fixture.effects.process(message.copy(style = Defines.TE_RAILTRAIL), 1200)) + } + } + + @Test fun `degenerate segments emit no particles and long trails and budgets have bounded work`() { + Fixture(budget = 3, maximumTrailPoints = 10).use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f.zero, 10), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.effects.process(TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(10000f, 0f, 0f)), 0) + assertEquals(3, fixture.particles.liveCount) + } + Fixture(maximumTrailPoints = 10).use { fixture -> + fixture.effects.process(TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(10000f, 0f, 0f)), 0) + assertEquals(10, fixture.particles.liveCount) + } + Fixture(budget = 0).use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f(100f, 0f, 0f), 10), 0) + assertEquals(0, fixture.particles.liveCount) + } + } +} From 09caa71ce394c16f97089b14893d3a98fe88004b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:31:44 +0100 Subject: [PATCH 16/42] Present expanding widow and nuke particle shells --- cake/README.md | 4 +- .../ingame/effects/ClientEffectsSystem.kt | 14 ++- .../cake/stages/ingame/effects/README.md | 33 +++++- .../stages/ingame/effects/ShellEffects.kt | 69 +++++++++++ .../stages/ingame/effects/ShellEffectsTest.kt | 108 ++++++++++++++++++ 5 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt diff --git a/cake/README.md b/cake/README.md index 51e2a077..ab20fe8d 100644 --- a/cake/README.md +++ b/cake/README.md @@ -173,8 +173,8 @@ models, and sounds, then open the client console: EOF and playback errors return to the console. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look do not control playback. Opening the console leaves playback running. -Some expansion temporary effects remain unrendered. Steam, lightning, and player -and monster heatbeams are supported. +Base-game and expansion temporary effects use Cake's rendering pipeline; +visual fidelity varies by effect family. Protocol-26 demos shipped with older Quake II releases, extended/rerelease protocols, and cinematic recordings are unsupported. For a compatible test file, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index e14a2851..b93055ef 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -103,6 +103,9 @@ class ClientEffectsSystem( paletteColor = { index -> resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) }, lights = dynamicLightSystem, playBubbleSound = { position -> playEffectSound("sound/weapons/lashit.wav", toVector3(position)) }) + private val shellEffects = ShellEffects(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = EXPLOSION_PARTICLE_FALLBACK) + }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -152,8 +155,8 @@ class ClientEffectsSystem( /** * Handles temp-entity messages (`svc_temp_entity`) already decoded into typed message classes. * - * The codec validates subtype/layout combinations. Valid styles without an implemented renderer - * are still ignored silently by style branch fall-through. + * The codec validates subtype/layout combinations. Family controllers provide models, particles, + * lights and sounds through the screen-owned presentation runtimes. */ fun processTempEntityMessage(msg: TEMessage) { when (msg) { @@ -169,7 +172,7 @@ class ClientEffectsSystem( is SteamTEMessage -> steamEffects.process(msg, currentTimeMsProvider()) // Flashlight and force-wall messages feed the shared light and particle runtimes. is FlashlightTEMessage, is ForceWallTEMessage -> expansionEffects.process(msg, currentTimeMsProvider()) - is WidowBeamOutTEMessage -> Unit + is WidowBeamOutTEMessage -> shellEffects.process(msg, currentTimeMsProvider()) } } @@ -210,6 +213,7 @@ class ClientEffectsSystem( val now = currentTimeMsProvider() particleSystem.update(now) steamEffects.update(now) + shellEffects.update(now) beamModels.beginFrame() val camera = cameraProvider() val localPlayer = localPlayerEntityIndexProvider() @@ -254,9 +258,10 @@ class ClientEffectsSystem( fun debugParticleRenderStats(): ParticleRenderer.Stats = particleSystem.lastRenderStats() - fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount + fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount + shellEffects.activeSustainCount override fun dispose() { + shellEffects.clear() heatbeamEffects.clear() beamEffects.clear() beamModels.dispose() @@ -550,6 +555,7 @@ class ClientEffectsSystem( } private fun handlePointMessage(msg: PointTEMessage) { + if (shellEffects.process(msg, currentTimeMsProvider())) return val position = toVector3(msg.position) when (msg.style) { Defines.TE_EXPLOSION2, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 50838728..890d59ed 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -64,7 +64,7 @@ is deferred until the world-pass depth, blending and color requirements are chec `r_particles` supplies the live emission budget: zero or negative values disable emission and drawing, while positive values limit new spawns to the remaining -budget. Overflow emits are dropped; there is no hard upper clamp. Lowering the +validated budget up to `MAX_PARTICLES`. Overflow emits are dropped. Lowering the budget does not immediately remove existing particles; they continue to age out. `ParticleRenderer` uploads dynamic mesh data rather than creating a `ModelInstance` @@ -143,6 +143,29 @@ a paused presentation time do not emit again. Nonpositive durations cancel an identifier. Clearing sustains stops future emission; existing particles finish fading, or are disposed with the screen's particle runtime during teardown. +## Other Expansion Effects + +`ExpansionEffects` routes flashlight messages to entity-keyed white dynamic lights +with a 100 ms lifetime. Repeated messages update the same light through +`DynamicLightSystem`, including its normal high-FPS visibility extension. Force +walls emit falling palette-colored particles along their segment; bubble trail 2 +emits a denser rising trail with the impact sound at its start. Segment sampling +has a work cap and all particles obey the shared budget. + +`ShellEffects` uses `SustainScheduler` and the libGDX particle runtime for expanding +widow beam-out and nuke shells. Widow shells expand toward a 45-unit radius over +2.1 seconds and refresh by wire identifier. Nuke shells expand toward 200 units +over one second; their messages have no identifier, so independent explosions +receive local IDs outside the wire's signed-short range. The two kinds share a +32-sustain capacity. Particle pulses run at 50 ms intervals, last 75 ms and use +family-specific palettes, with counts tuned for the existing particle budget. +Paused clocks do not emit again, stalls skip missed pulses, and clearing sustain +state stops future emission while existing particles finish fading. + +Tests use synthetic messages, clocks and palette values with the real light and +particle runtimes to exercise refresh, motion, sound requests, coexistence, +expiry, pause, resource limits and cleanup. No installed content or window is needed. + ## Visual Behaviour and Current Limits Particle families select colours from `q2palette.bin`. Useful compatibility anchors: @@ -155,10 +178,10 @@ Particle families select colours from `q2palette.bin`. Useful compatibility anch Fallback colours keep effects visible when palette data is unavailable. `TE_BFG_EXPLOSION` uses the translucent `sprites/s_bfg2.sp2` explosion path; `TE_BFG_LASER` remains a procedural `LineBeamEffect` approximation. Parasite/medic -and grapple beams use pooled segment models rebuilt from persistent beam state. Visual parity varies by effect -family; unsupported styles inside recognised TE message classes can fall through -silently. The decoded expansion message classes with no presentation implementation -are currently ignored explicitly. +and grapple beams use pooled segment models rebuilt from persistent beam state. Visual fidelity varies by effect +family. Decoded message families dispatch to their presentation handlers; some +existing effects still approximate the reference particle patterns or omit components +which are covered by the remaining fidelity work. Legacy references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, `CL_tent.ParseTEnt`, and `../yquake2/src/client/cl_effects.c`. diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt new file mode 100644 index 00000000..98848705 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt @@ -0,0 +1,69 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import jake2.qcommon.network.messages.server.TEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage +import kotlin.random.Random + +/** CL_Widowbeamout / CL_Nukeblast, presented as bounded expanding particle pulses through libGDX. */ +internal class ShellEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, + maximumSustains: Int = 32, +) { + private enum class Kind(val duration: Int, val radius: Float, val count: Int, val palette: List) { + WIDOW(2100, 45f, 128, listOf(16, 104, 168, 144)), + NUKE(1000, 200f, 256, listOf(110, 112, 114, 116)), + } + private data class Shell(val kind: Kind, val origin: Vector3f, val startedAt: Int) + private val sustains = SustainScheduler(maximumSustains) + private var nextNukeId = Short.MAX_VALUE + 1 + private val direction = Vector3() + private val position = Vector3() + val activeSustainCount: Int get() = sustains.activeCount + + fun process(message: TEMessage, nowMs: Int): Boolean { + val kind: Kind + val origin: Vector3f + val id: Int + when (message) { + is WidowBeamOutTEMessage -> { kind = Kind.WIDOW; origin = message.position; id = message.id } + is PointTEMessage -> { + if (message.style != Defines.TE_NUKEBLAST) return false + kind = Kind.NUKE; origin = message.position + // Nuke messages have no identifier. Keep independent explosions outside the wire's signed-short ID range. + id = nextNukeId + nextNukeId = if (nextNukeId == Int.MAX_VALUE) Short.MAX_VALUE + 1 else nextNukeId + 1 + } + else -> return false + } + sustains.start(id, Shell(kind, origin, nowMs), nowMs, kind.duration, intervalMillis = 50) + return true + } + + fun update(nowMs: Int) { + sustains.update(nowMs) pulse@ { shell -> + val progress = ((nowMs.toLong() - shell.startedAt).toFloat() / shell.kind.duration).coerceIn(0f, 1f) + val radius = shell.kind.radius * progress + repeat(shell.kind.count) { + direction.set(random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f) + if (direction.isZero) direction.set(Vector3.Z) + position.set(shell.origin.x, shell.origin.y, shell.origin.z).mulAdd(direction.nor(), radius) + val color = shell.kind.palette[random.nextInt(shell.kind.palette.size)] + if (!particles.emit(position, Vector3.Zero, Vector3.Zero, paletteColor(color), size = .6f, + lifetimeMillis = 75, nowMs = nowMs)) return@pulse + } + } + } + + /** Stop future pulses; existing particles remain owned by the shared particle runtime. */ + fun clear() { + sustains.clear() + nextNukeId = Short.MAX_VALUE + 1 + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt new file mode 100644 index 00000000..ff2eb874 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt @@ -0,0 +1,108 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ShellEffectsTest { + private class Fixture(budget: Int = 10000, maximumSustains: Int = 8) : AutoCloseable { + val particles = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val effects = ShellEffects(particles, { index -> colors += index; Color.WHITE }, Random(7), maximumSustains) + fun advance(nowMs: Int) { particles.update(nowMs); effects.update(nowMs) } + fun positions(nowMs: Int): List = buildList { + particles.submit(nowMs, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() { effects.clear(); particles.dispose() } + } + + @Test fun `widow beam-out expands over its lifetime using its palette and freezes on a paused clock`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f.zero), 0) + fixture.advance(0) + val count = fixture.particles.liveCount + assertTrue(count > 0) + assertTrue(fixture.positions(0).all { it.isZero }) + fixture.advance(1050) + val halfway = fixture.positions(1050) + assertEquals(count, halfway.size) // A stall produces one current shell, not a backlog of pulses. + assertTrue(halfway.all { kotlin.math.abs(it.len() - 22.5f) < .001f }) + assertTrue(fixture.colors.all { it in setOf(16, 104, 168, 144) }) + repeat(100) { fixture.advance(1050) } + assertEquals(halfway, fixture.positions(1050)) + fixture.advance(2100) + assertEquals(0, fixture.effects.activeSustainCount) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `nuke blast expands independently and expires after one second`() { + Fixture().use { fixture -> + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(500) + val halfway = fixture.positions(500) + assertTrue(halfway.isNotEmpty()) + assertTrue(halfway.all { kotlin.math.abs(it.len() - 100f) < .001f }) + assertTrue(fixture.colors.all { it in setOf(110, 112, 114, 116) }) + fixture.advance(1000) + assertEquals(0, fixture.effects.activeSustainCount) + assertTrue(fixture.positions(1000).isEmpty()) + } + } + + @Test fun `nukes coexist with each other and with wire-identified widow effects`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(21000, Vector3f.zero), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f(1000f, 0f, 0f)), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f(2000f, 0f, 0f)), 0) + fixture.advance(0) + assertEquals(3, fixture.effects.activeSustainCount) + val groups = fixture.positions(0).groupBy { it.x } + assertEquals(setOf(0f, 1000f, 2000f), groups.keys) + assertTrue(groups.values.all { it.isNotEmpty() }) + } + } + + @Test fun `repeated widow identifiers replace their origin and refresh lifetime`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f.zero), 0) + fixture.advance(1000) + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f(100f, 0f, 0f)), 1100) + fixture.advance(1100) + assertEquals(1, fixture.effects.activeSustainCount) + assertTrue(fixture.positions(1100).all { it == Vector3(100f, 0f, 0f) }) + fixture.advance(2200) + assertEquals(1, fixture.effects.activeSustainCount) + fixture.advance(3200) + assertEquals(0, fixture.effects.activeSustainCount) + } + } + + @Test fun `sustain capacity particle budgets and teardown bound all future work`() { + Fixture(budget = 3, maximumSustains = 1).use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(1, Vector3f.zero), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(0) + assertEquals(1, fixture.effects.activeSustainCount) + assertEquals(3, fixture.particles.liveCount) + fixture.effects.clear() + fixture.advance(100) + assertEquals(0, fixture.effects.activeSustainCount) + assertEquals(0, fixture.particles.liveCount) + } + Fixture(budget = 0).use { fixture -> + assertFalse(fixture.effects.process(PointTEMessage(Defines.TE_EXPLOSION1, Vector3f.zero), 0)) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(0) + assertEquals(0, fixture.particles.liveCount) + fixture.advance(2000) + assertEquals(0, fixture.effects.activeSustainCount) + } + } +} From 76534d11e2c8837aab2f64dac5c60379c9d46ed1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:39:44 +0100 Subject: [PATCH 17/42] Restore boss teleport and tracker explosion particle components --- .../ingame/effects/ClientEffectsSystem.kt | 7 +- .../stages/ingame/effects/PointParticles.kt | 56 +++++++++++++++ .../ingame/effects/PointParticlesTest.kt | 69 +++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index b93055ef..189cefc6 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -106,6 +106,9 @@ class ClientEffectsSystem( private val shellEffects = ShellEffects(particleSystem.runtime, paletteColor = { index -> resolvePaletteColor(index, fallback = EXPLOSION_PARTICLE_FALLBACK) }) + private val pointParticles = PointParticles(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) + }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -555,7 +558,9 @@ class ClientEffectsSystem( } private fun handlePointMessage(msg: PointTEMessage) { - if (shellEffects.process(msg, currentTimeMsProvider())) return + val now = currentTimeMsProvider() + if (shellEffects.process(msg, now)) return + pointParticles.process(msg, now) val position = toVector3(msg.position) when (msg.style) { Defines.TE_EXPLOSION2, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt new file mode 100644 index 00000000..1539157e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt @@ -0,0 +1,56 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.PointTEMessage +import kotlin.random.Random + +/** Particle components of CL_BigTeleportParticles and CL_ColorExplosionParticles, backed by libGDX. */ +internal class PointParticles( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, +) { + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + private val teleportPalette = intArrayOf(16, 104, 168, 144) + + fun process(message: PointTEMessage, nowMs: Int): Boolean { + val teleport = when (message.style) { + Defines.TE_BOSSTPORT -> true + Defines.TE_TRACKER_EXPLOSION -> false + else -> return false + } + repeat(if (teleport) 768 else 128) { + val color: Int + val lifetime: Int + if (teleport) { + val angle = random.nextFloat() * MathUtils.PI2 + val x = MathUtils.cos(angle); val y = MathUtils.sin(angle) + val radius = random.nextFloat() * 32f + position.set(message.position.x + x * radius, message.position.y + y * radius, + message.position.z + 8f + random.nextFloat() * 90f) + velocity.set(x * (70f + random.nextFloat() * 63f), y * (70f + random.nextFloat() * 63f), + -100f + random.nextFloat() * 31f) + acceleration.set(-x * 100f, -y * 100f, 160f) + color = teleportPalette[random.nextInt(teleportPalette.size)] + lifetime = random.nextInt(1600, 2601) + } else { + position.set(message.position.x + signedRandom() * 16f, message.position.y + signedRandom() * 16f, + message.position.z + signedRandom() * 16f) + velocity.set(signedRandom() * 128f, signedRandom() * 128f, signedRandom() * 128f) + acceleration.set(0f, 0f, -80f) + color = 0 + lifetime = random.nextInt(1500, 2001) + } + if (!particles.emit(position, velocity, acceleration, paletteColor(color), size = .6f, + lifetimeMillis = lifetime, nowMs = nowMs)) return true + } + return true + } + + private fun signedRandom() = random.nextFloat() * 2f - 1f +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt new file mode 100644 index 00000000..57992549 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt @@ -0,0 +1,69 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class PointParticlesTest { + private class Fixture(budget: Int = 1000) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val particles = PointParticles(runtime, { colors += it; Color.WHITE }, Random(12)) + fun positions(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = runtime.dispose() + } + + @Test fun `boss teleport creates a tall radial column in its palette instead of only sound`() { + Fixture().use { fixture -> + fixture.particles.process(PointTEMessage(Defines.TE_BOSSTPORT, Vector3f.zero), 0) + val start = fixture.positions(0) + assertTrue(start.isNotEmpty()) + assertTrue(start.all { it.z in 8f..98f && it.x * it.x + it.y * it.y <= 1024.01f }) + assertTrue(fixture.colors.all { it in setOf(16, 104, 168, 144) }) + val later = fixture.positions(100) + assertTrue(start.zip(later).all { (before, after) -> after.z < before.z }) + assertTrue(start.zip(later).any { (before, after) -> after.x * after.x + after.y * after.y > before.x * before.x + before.y * before.y }) + assertTrue(fixture.positions(2601).isEmpty()) + } + } + + @Test fun `tracker emits an owned black particle cloud with independent motion and fade`() { + Fixture().use { fixture -> + fixture.particles.process(PointTEMessage(Defines.TE_TRACKER_EXPLOSION, Vector3f(100f, 200f, 300f)), 0) + val initial = fixture.positions(0) + assertTrue(initial.isNotEmpty()) + assertTrue(initial.all { it.x in 84f..116f && it.y in 184f..216f && it.z in 284f..316f }) + assertTrue(fixture.colors.all { it == 0 }) + val later = fixture.positions(100) + assertNotEquals(initial, later) + repeat(10) { assertEquals(later, fixture.positions(100)) } + assertTrue(fixture.positions(2001).isEmpty()) + } + } + + @Test fun `burst work respects disabled and exhausted particle budgets and ignores unrelated point effects`() { + for (style in listOf(Defines.TE_BOSSTPORT, Defines.TE_TRACKER_EXPLOSION)) { + Fixture(3).use { fixture -> + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(3, fixture.runtime.liveCount) + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(3, fixture.runtime.liveCount) + } + Fixture(0).use { fixture -> + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(0, fixture.runtime.liveCount) + } + } + Fixture().use { fixture -> + assertFalse(fixture.particles.process(PointTEMessage(Defines.TE_EXPLOSION1, Vector3f.zero), 0)) + assertEquals(0, fixture.runtime.liveCount) + } + } +} From a73e1b516b58a4a79f2b54745fc704b67ab84dbc Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:43:01 +0100 Subject: [PATCH 18/42] Use family-specific impact particles counts and sound probabilities --- .../ingame/effects/ClientEffectsSystem.kt | 299 ++---------------- .../stages/ingame/effects/ImpactEffects.kt | 114 +++++++ .../ingame/effects/ImpactEffectsTest.kt | 138 ++++++++ 3 files changed, 273 insertions(+), 278 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 189cefc6..d5787790 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -109,6 +109,9 @@ class ClientEffectsSystem( private val pointParticles = PointParticles(particleSystem.runtime, paletteColor = { index -> resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) }) + private val impactEffects = ImpactEffects(particleSystem.runtime, + paletteColor = { index -> resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) }, + playSound = { request -> playEffectSound(request.path, toVector3(request.origin), attenuation = request.attenuation) }) // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -323,107 +326,32 @@ class ClientEffectsSystem( private fun handlePointDirectionMessage(msg: PointDirectionTEMessage) { val position = toVector3(msg.position) - if (steamEffects.processImpact(msg, currentTimeMsProvider())) { + val now = currentTimeMsProvider() + if (steamEffects.processImpact(msg, now)) { playEffectSound("sound/weapons/lashit.wav", position) return } + impactEffects.process(msg, now) when (msg.style) { - Defines.TE_BLOOD, - Defines.TE_MOREBLOOD, - Defines.TE_GREENBLOOD -> { - val paletteBase = when (msg.style) { - Defines.TE_GREENBLOOD -> GREEN_BLOOD_PARTICLE_BASE_COLOR - else -> BLOOD_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = when (msg.style) { - Defines.TE_MOREBLOOD -> 250 - Defines.TE_GREENBLOOD -> 30 - else -> 60 - }, - paletteBase = paletteBase, - ) - } - - Defines.TE_GUNSHOT, - Defines.TE_SPARKS, - Defines.TE_BULLET_SPARKS -> { - val (count, paletteBase) = when (msg.style) { - Defines.TE_GUNSHOT -> 40 to GUNSHOT_PARTICLE_BASE_COLOR - else -> 6 to SPARK_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = count, - paletteBase = paletteBase, - ) - if (msg.style != Defines.TE_SPARKS) { - spawnSmokeAndFlash(position) - playRandomSound(RICHOCHET_SOUNDS, position) - } - } - - Defines.TE_SCREEN_SPARKS, - Defines.TE_SHIELD_SPARKS -> { - val paletteBase = if (msg.style == Defines.TE_SCREEN_SPARKS) { - SCREEN_SPARK_PARTICLE_BASE_COLOR - } else { - SHIELD_SPARK_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 40, - paletteBase = paletteBase, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_ELECTRIC_SPARKS -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 40, - paletteBase = ELECTRIC_SPARK_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_SHOTGUN -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 20, - paletteBase = SHOTGUN_PARTICLE_BASE_COLOR, - ) - spawnSmokeAndFlash(position) - } - + Defines.TE_GUNSHOT, Defines.TE_BULLET_SPARKS, Defines.TE_SHOTGUN -> spawnSmokeAndFlash(position) Defines.TE_BLASTER -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 0) spawnDynamicLight(position, 150f, 1f, 1f, 0f) - playEffectSound("sound/weapons/lashit.wav", position) } - Defines.TE_BLASTER2 -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 1) spawnDynamicLight(position, 150f, 0f, 1f, 0f) - playEffectSound("sound/weapons/lashit.wav", position) } - Defines.TE_FLECHETTE -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 2) spawnDynamicLight(position, 150f, 0.19f, 0.41f, 0.75f) - playEffectSound("sound/weapons/lashit.wav", position) } } } private fun handleTrailMessage(msg: TrailTEMessage) { - if (expansionEffects.process(msg, currentTimeMsProvider())) return + val now = currentTimeMsProvider() + if (expansionEffects.process(msg, now) || impactEffects.process(msg, now)) return val start = toVector3(msg.position) val end = toVector3(msg.destination) when (msg.style) { @@ -450,15 +378,6 @@ class ClientEffectsSystem( ) } - Defines.TE_BLUEHYPERBLASTER -> { - emitPaletteImpactParticles( - origin = start, - direction = msg.destination.toArray(), - count = 40, - paletteBase = BLUE_HYPERBLASTER_PARTICLE_BASE_COLOR, - ) - } - Defines.TE_DEBUGTRAIL -> { emitSegmentTrailParticles( start = start, @@ -502,58 +421,17 @@ class ClientEffectsSystem( } private fun handleSplashMessage(msg: SplashTEMessage) { - val position = toVector3(msg.position) - when (msg.style) { - Defines.TE_SPLASH -> { - val splashPaletteBase = LEGACY_SPLASH_COLORS.getOrNull(msg.param) ?: LEGACY_SPLASH_DEFAULT_COLOR - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count.coerceIn(4, 64), - paletteBase = splashPaletteBase, - ) - if (msg.param == Defines.SPLASH_SPARKS) { - playRandomSound(SPARK_SOUNDS, position, Defines.ATTN_STATIC.toFloat()) - } - } - - Defines.TE_LASER_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = -320f, - ) - } - - Defines.TE_WELDING_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = -320f, - ) - spawnLegacyExplosionEffect( - type = ExplosionType.FLASH, - modelPath = "models/objects/flash/tris.md2", - position = position, - firstFrame = 0, - frameCount = 2, - ) - spawnDynamicLight(position, (100 + Globals.rnd.nextInt(75)).toFloat(), 1f, 1f, 0.3f) - } - - Defines.TE_TUNNEL_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = 320f, - ) - } + impactEffects.process(msg, currentTimeMsProvider()) + if (msg.style == Defines.TE_WELDING_SPARKS) { + val position = toVector3(msg.position) + spawnLegacyExplosionEffect( + type = ExplosionType.FLASH, + modelPath = "models/objects/flash/tris.md2", + position = position, + firstFrame = 0, + frameCount = 2, + ) + spawnDynamicLight(position, (100 + Globals.rnd.nextInt(75)).toFloat(), 1f, 1f, 0.3f) } } @@ -561,6 +439,7 @@ class ClientEffectsSystem( val now = currentTimeMsProvider() if (shellEffects.process(msg, now)) return pointParticles.process(msg, now) + impactEffects.process(msg, now) val position = toVector3(msg.position) when (msg.style) { Defines.TE_EXPLOSION2, @@ -654,12 +533,6 @@ class ClientEffectsSystem( } Defines.TE_CHAINFIST_SMOKE -> { - emitPaletteImpactParticles( - origin = position, - direction = floatArrayOf(0f, 0f, 1f), - count = 20, - paletteBase = CHAINFIST_SMOKE_PARTICLE_BASE_COLOR, - ) spawnAnimatedModelEffect( modelPath = "models/objects/smoke/tris.md2", position = position, @@ -934,87 +807,6 @@ class ClientEffectsSystem( ) } - private fun emitImpactParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - color: Color, - ) { - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = count, - color = color, - speedMin = 35f, - speedMax = 170f, - spread = 0.85f, - gravity = -320f, - startAlpha = 0.95f, - endAlpha = 0f, - sizeMin = 0.4f, - sizeMax = 1.3f, - lifetimeMinMs = 160, - lifetimeMaxMs = 520, - ) - } - - private fun emitPaletteImpactParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - paletteBase: Int, - paletteVariants: Int = 8, - ) { - val safeCount = count.coerceIn(1, 512) - val variantCount = paletteVariants.coerceAtLeast(1) - repeat(safeCount) { - val paletteIndex = paletteBase + if (variantCount > 1) { - Globals.rnd.nextInt(variantCount) - } else { - 0 - } - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = 1, - color = resolvePaletteColor(paletteIndex, fallback = IMPACT_PARTICLE_COLOR_FALLBACK), - speedMin = 35f, - speedMax = 170f, - spread = 0.85f, - gravity = -320f, - startAlpha = 0.95f, - endAlpha = 0f, - sizeMin = 0.4f, - sizeMax = 1.3f, - lifetimeMinMs = 160, - lifetimeMaxMs = 520, - ) - } - } - - private fun emitExplosionParticles( - origin: Vector3, - count: Int, - color: Color, - ) { - particleSystem.emitBurst( - origin = origin, - direction = floatArrayOf(0f, 0f, 1f), - count = count, - color = color, - speedMin = 60f, - speedMax = 320f, - spread = 1.2f, - gravity = -210f, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.6f, - sizeMax = 2f, - lifetimeMinMs = 240, - lifetimeMaxMs = 900, - ) - } - private fun emitLegacyExplosionPaletteParticles( origin: Vector3, count: Int, @@ -1232,31 +1024,6 @@ class ClientEffectsSystem( } } - private fun emitLegacyPaletteParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - paletteIndex: Int, - gravity: Float, - ) { - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = count.coerceIn(1, 128), - color = resolvePaletteColor(paletteIndex, fallback = Color(1f, 0.85f, 0.4f, 1f)), - speedMin = 8f, - speedMax = 48f, - spread = 0.55f, - gravity = gravity, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.3f, - sizeMax = 1f, - lifetimeMinMs = 180, - lifetimeMaxMs = 560, - ) - } - private fun emitBfgBigExplosionParticles(origin: Vector3) { emitPaletteOmniBurst( origin = origin, @@ -1614,12 +1381,6 @@ class ClientEffectsSystem( } } -private val RICHOCHET_SOUNDS = listOf( - "sound/world/ric1.wav", - "sound/world/ric2.wav", - "sound/world/ric3.wav", -) - // Visual target: Quake2 rail trail (blue spiral with bright white center). private const val RAIL_TRAIL_MAX_SAMPLES = 4096 private const val RAIL_TRAIL_SPIRAL_PHASE_STEP = 0.1f @@ -1645,17 +1406,8 @@ private const val RAIL_TRAIL_CORE_COLOR_BASE = 0x00 private const val RAIL_TRAIL_CORE_COLOR_VARIANTS = 16 private val RAIL_TRAIL_CORE_COLOR_FALLBACK = Color(0.94f, 0.94f, 0.94f, 1f) -private const val BLOOD_PARTICLE_BASE_COLOR = 0xE8 -private const val GREEN_BLOOD_PARTICLE_BASE_COLOR = 0xDF -private const val GUNSHOT_PARTICLE_BASE_COLOR = 0x00 -private const val SPARK_PARTICLE_BASE_COLOR = 0xE0 -private const val SCREEN_SPARK_PARTICLE_BASE_COLOR = 0xD0 -private const val SHIELD_SPARK_PARTICLE_BASE_COLOR = 0xB0 -private const val SHOTGUN_PARTICLE_BASE_COLOR = 0x00 private val HEATBEAM_PARTICLE_FALLBACK = Color(1f, .7f, .2f, 1f) private val STEAM_PARTICLE_FALLBACK = Color(.75f, .75f, .75f, 1f) -private const val ELECTRIC_SPARK_PARTICLE_BASE_COLOR = 0x75 -private const val CHAINFIST_SMOKE_PARTICLE_BASE_COLOR = 0x00 private const val EXPLOSION_PARTICLE_BASE_COLOR = 0xE0 private val IMPACT_PARTICLE_COLOR_FALLBACK = Color(0.95f, 0.9f, 0.65f, 1f) @@ -1685,12 +1437,9 @@ private const val BLASTER_TRAIL_LIFETIME_MIN_MS = 300 private const val BLASTER_TRAIL_LIFETIME_MAX_MS = 500 private const val BLASTER_TRAIL_COLOR_INDEX = 0xE0 private const val BLASTER2_TRAIL_COLOR_INDEX = 0xD0 -private const val BLUE_HYPERBLASTER_PARTICLE_BASE_COLOR = 0xE0 private val BLASTER_TRAIL_COLOR_FALLBACK = Color(1f, 0.87f, 0.35f, 1f) private val BLASTER2_TRAIL_COLOR_FALLBACK = Color(0.3f, 0.95f, 0.3f, 1f) -private val LEGACY_SPLASH_COLORS = intArrayOf(0x00, 0xE0, 0xB0, 0x50, 0xD0, 0xE0, 0xE8) -private const val LEGACY_SPLASH_DEFAULT_COLOR = 0x00 private const val DIMINISH_TRAIL_STEP = 0.5f private const val DIMINISH_TRAIL_HEAVY_ORIGIN_SCALE = 4f @@ -1748,9 +1497,3 @@ private const val RESPAWN_EVENT_COLOR_BASE = 0xE0 private val LOGIN_EVENT_COLOR_FALLBACK = Color(0.35f, 0.92f, 0.45f, 1f) private val LOGOUT_EVENT_COLOR_FALLBACK = Color(0.95f, 0.25f, 0.2f, 1f) private val RESPAWN_EVENT_COLOR_FALLBACK = Color(1f, 0.9f, 0.3f, 1f) - -private val SPARK_SOUNDS = listOf( - "sound/world/spark5.wav", - "sound/world/spark6.wav", - "sound/world/spark7.wav", -) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt new file mode 100644 index 00000000..f4bed325 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt @@ -0,0 +1,114 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import kotlin.random.Random + +internal data class ImpactSoundRequest(val path: String, val origin: Vector3f, val attenuation: Float = ATTN_NORM.toFloat()) + +/** + * Particle and sound components of CL_ParticleEffect/2/3 and CL_BlasterParticles/2. + * Positions scatter along the impact normal; velocities scatter independently, with directed + * velocity for blaster impacts. The outer controller retains model flashes and dynamic lights. + */ +internal class ImpactEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val playSound: (ImpactSoundRequest) -> Unit, + private val random: Random = Random.Default, +) { + private data class Recipe(val count: Int, val palette: Int, val distance: Float = 31f, + val speed: Float = 20f, val forwardSpeed: Float = 0f, + val variants: Int = 8, val gravity: Float = -80f) + private val direction = Vector3() + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + + fun process(message: TEMessage, nowMs: Int): Boolean { + when (message) { + is PointDirectionTEMessage -> { + val recipe = when (message.style) { + TE_BLOOD -> Recipe(60, 0xE8) + TE_MOREBLOOD -> Recipe(250, 0xE8) + TE_GREENBLOOD -> Recipe(30, 0xDF, distance = 7f) + TE_GUNSHOT -> Recipe(40, 0) + TE_SPARKS, TE_BULLET_SPARKS -> Recipe(6, 0xE0) + TE_SCREEN_SPARKS -> Recipe(40, 0xD0) + TE_SHIELD_SPARKS -> Recipe(40, 0xB0) + TE_ELECTRIC_SPARKS -> Recipe(40, 0x75) + TE_SHOTGUN -> Recipe(20, 0) + TE_BLASTER -> blaster(0xE0) + TE_BLASTER2 -> blaster(0xD0) + TE_FLECHETTE -> blaster(0x6F) + else -> return false + } + emit(message.position, message.direction, recipe, nowMs) + when (message.style) { + TE_GUNSHOT, TE_BULLET_SPARKS -> { + val roll = random.nextInt(16) + if (roll in 1..3) playSound(ImpactSoundRequest("sound/world/ric$roll.wav", message.position)) + } + TE_SCREEN_SPARKS, TE_SHIELD_SPARKS, TE_ELECTRIC_SPARKS, TE_BLASTER, TE_BLASTER2, TE_FLECHETTE -> + playSound(ImpactSoundRequest("sound/weapons/lashit.wav", message.position)) + } + } + is SplashTEMessage -> { + val recipe = when (message.style) { + TE_SPLASH -> Recipe(message.count, SPLASH_COLORS.getOrElse(message.param) { 0 }) + TE_LASER_SPARKS, TE_WELDING_SPARKS -> Recipe(message.count, message.param, distance = 7f) + TE_TUNNEL_SPARKS -> Recipe(message.count, message.param, distance = 7f, variants = 1, gravity = 80f) + else -> return false + } + emit(message.position, message.direction, recipe, nowMs) + if (message.style == TE_SPLASH && message.param == SPLASH_SPARKS) { + val sound = when (random.nextInt(4)) { 0 -> 5; 1 -> 6; else -> 7 } + playSound(ImpactSoundRequest("sound/world/spark$sound.wav", message.position, ATTN_STATIC.toFloat())) + } + } + is TrailTEMessage -> { + if (message.style != TE_BLUEHYPERBLASTER) return false + // This subtype transmits a direction with position precision, rather than a trail endpoint. + emit(message.position, message.destination, blaster(0xE0), nowMs) + } + is PointTEMessage -> { + if (message.style != TE_CHAINFIST_SMOKE) return false + emitSmoke(message.position, nowMs) + } + else -> return false + } + return true + } + + private fun blaster(palette: Int) = Recipe(40, palette, distance = 15f, speed = 40f, forwardSpeed = 30f) + + private fun emit(origin: Vector3f, normal: Vector3f, recipe: Recipe, nowMs: Int) { + direction.set(normal.x, normal.y, normal.z).nor() + acceleration.set(0f, 0f, recipe.gravity) + repeat(recipe.count) { + position.set(origin.x + signedRandom() * 4f, origin.y + signedRandom() * 4f, origin.z + signedRandom() * 4f) + .mulAdd(direction, random.nextFloat() * recipe.distance) + velocity.set(signedRandom() * recipe.speed, signedRandom() * recipe.speed, signedRandom() * recipe.speed) + .mulAdd(direction, recipe.forwardSpeed) + val color = (recipe.palette + random.nextInt(recipe.variants)) and 255 + if (!particles.emit(position, velocity, acceleration, paletteColor(color), size = .5f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return + } + } + + private fun emitSmoke(origin: Vector3f, nowMs: Int) { + repeat(20) { + position.set(origin.x + signedRandom() * 2f, origin.y + signedRandom() * 2f, origin.z + signedRandom() * 2f) + velocity.set(signedRandom() * 7f, signedRandom() * 7f, 20f) + if (!particles.emit(position, velocity, Vector3.Zero, paletteColor(random.nextInt(8)), size = .7f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return + } + } + + private fun signedRandom() = random.nextFloat() * 2f - 1f + + companion object { private val SPLASH_COLORS = intArrayOf(0x00, 0xE0, 0xB0, 0x50, 0xD0, 0xE0, 0xE8) } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt new file mode 100644 index 00000000..e8795209 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt @@ -0,0 +1,138 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.DirectionCodec +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ImpactEffectsTest { + private class FixedBits(private val value: Int) : Random() { + override fun nextBits(bitCount: Int) = if (bitCount == 0) 0 else value and (-1 ushr (32 - bitCount)) + } + private class Fixture(budget: Int = 1000, random: Random = Random(7)) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val sounds = mutableListOf() + val effects = ImpactEffects(runtime, { colors += it; Color.WHITE }, sounds::add, random) + fun positions(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = runtime.dispose() + } + private val direction = DirectionCodec.index(Vector3f.unitX) + private fun impact(style: Int) = PointDirectionTEMessage(style, Vector3f.zero, direction) + + @Test fun `blaster variants emit normal-directed bursts in their own palette with the impact sound`() { + for ((style, palette) in listOf(TE_BLASTER to 0xE0, TE_BLASTER2 to 0xD0, TE_FLECHETTE to 0x6F)) Fixture().use { fixture -> + assertTrue(fixture.effects.process(impact(style), 0)) + val first = fixture.positions(0) + assertTrue(first.isNotEmpty()) + assertTrue(first.all { it.x in -4f..19f && it.y in -4f..4f && it.z in -4f..4f }) + assertTrue(fixture.colors.all { it in palette..palette + 7 }) + for ((before, after) in first.zip(fixture.positions(100))) assertTrue(after.x - before.x in -1.001f..7.001f) + assertEquals(listOf("sound/weapons/lashit.wav"), fixture.sounds.map { it.path }) + assertTrue(fixture.positions(801).isEmpty()) + } + Fixture().use { fixture -> + fixture.effects.process(TrailTEMessage(TE_BLUEHYPERBLASTER, Vector3f.zero, Vector3f.unitX), 0) + assertTrue(fixture.positions(0).isNotEmpty()) + assertTrue(fixture.colors.all { it in 0xE0..0xE7 }) + assertTrue(fixture.sounds.isEmpty()) + } + } + + @Test fun `ordinary impacts scatter positions along the normal and velocities independently`() { + Fixture().use { fixture -> + fixture.effects.process(impact(TE_GUNSHOT), 0) + val first = fixture.positions(0) + assertTrue(first.all { it.x in -4f..35f && it.y in -4f..4f && it.z in -4f..4f }) + val motion = first.zip(fixture.positions(100)).map { (before, after) -> after.x - before.x } + assertTrue(motion.any { it > 0f } && motion.any { it < 0f }) + assertTrue(motion.all { it in -2.001f..2.001f }) + } + } + + @Test fun `all splash families respect zero small and large transmitted counts without private caps`() { + for (style in SplashTEMessage.SUBTYPES) for (count in listOf(0, 1, 64, 128, 255)) Fixture().use { fixture -> + val parameter = if (style == TE_SPLASH) 2 else 200 + fixture.effects.process(SplashTEMessage(style, count, Vector3f.zero, direction, parameter), 0) + assertEquals(count, fixture.runtime.liveCount, "Style $style count $count") + val palette = if (style == TE_SPLASH) 0xB0 else 200 + assertTrue(fixture.colors.all { if (style == TE_TUNNEL_SPARKS) it == palette else it in palette..palette + 7 }) + } + } + + @Test fun `tunnel sparks accelerate upwards while laser and welding sparks fall`() { + for (style in listOf(TE_TUNNEL_SPARKS, TE_LASER_SPARKS, TE_WELDING_SPARKS)) Fixture().use { fixture -> + fixture.effects.process(SplashTEMessage(style, 3, Vector3f.zero, direction, 20), 0) + val first = fixture.positions(0) + val second = fixture.positions(100) + val third = fixture.positions(200) + for (index in first.indices) { + val curvature = third[index].z - 2f * second[index].z + first[index].z + assertTrue(if (style == TE_TUNNEL_SPARKS) curvature > 0f else curvature < 0f) + } + } + } + + @Test fun `gunshot ricochets are occasional and particle budgets do not mute audio`() { + for (style in listOf(TE_GUNSHOT, TE_BULLET_SPARKS)) { + val heard = mutableListOf() + for (roll in 0..15) Fixture(budget = 0, random = FixedBits(roll)).use { fixture -> + fixture.effects.process(impact(style), 0) + assertEquals(if (roll in 1..3) 1 else 0, fixture.sounds.size) + assertTrue(fixture.sounds.all { it.origin == Vector3f.zero && it.attenuation == ATTN_NORM.toFloat() }) + heard += fixture.sounds.map { it.path } + } + assertEquals(listOf("sound/world/ric1.wav", "sound/world/ric2.wav", "sound/world/ric3.wav"), heard) + } + Fixture(budget = 0).use { fixture -> + fixture.effects.process(impact(TE_SPARKS), 0) + fixture.effects.process(impact(TE_SHOTGUN), 0) + assertTrue(fixture.sounds.isEmpty()) + fixture.effects.process(impact(TE_BLASTER), 0) + assertEquals("sound/weapons/lashit.wav", fixture.sounds.single().path) + } + } + + @Test fun `spark splashes use weighted sound variants with static attenuation even at zero count`() { + val heard = mutableListOf() + for (roll in 0..3) Fixture(budget = 0, random = FixedBits(roll)).use { fixture -> + fixture.effects.process(SplashTEMessage(TE_SPLASH, 0, Vector3f.zero, direction, SPLASH_SPARKS), 0) + val sound = fixture.sounds.single() + assertEquals(ATTN_STATIC.toFloat(), sound.attenuation) + heard += sound.path + } + assertEquals(listOf("sound/world/spark5.wav", "sound/world/spark6.wav", "sound/world/spark7.wav", "sound/world/spark7.wav"), heard) + } + + @Test fun `chainfist smoke rises without downward acceleration and particles remain budget limited`() { + Fixture().use { fixture -> + fixture.effects.process(PointTEMessage(TE_CHAINFIST_SMOKE, Vector3f.zero), 0) + val first = fixture.positions(0) + assertTrue(first.isNotEmpty()) + assertTrue(fixture.colors.all { it in 0..7 }) + for ((before, after) in first.zip(fixture.positions(100))) assertEquals(2f, after.z - before.z, .001f) + } + Fixture(budget = 3).use { fixture -> + fixture.effects.process(SplashTEMessage(TE_SPLASH, 255, Vector3f.zero, direction, 0), 0) + assertEquals(3, fixture.runtime.liveCount) + } + } + + @Test fun `directional family coverage excludes only heatbeam steam handled by its own controller`() { + for (style in PointDirectionTEMessage.SUBTYPES - setOf(TE_HEATBEAM_SPARKS, TE_HEATBEAM_STEAM)) Fixture(budget = 0).use { + assertTrue(it.effects.process(impact(style), 0), "Style $style") + } + Fixture().use { + assertFalse(it.effects.process(impact(TE_HEATBEAM_STEAM), 0)) + assertFalse(it.effects.process(PointTEMessage(TE_EXPLOSION1, Vector3f.zero), 0)) + assertFalse(it.effects.process(TrailTEMessage(TE_RAILTRAIL, Vector3f.zero, Vector3f.unitX), 0)) + } + } +} From ba6206a234eef748cb76eb5120dddaa91c31c47b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 20:45:36 +0100 Subject: [PATCH 19/42] Refine retained particle motion and document family coverage --- .../ingame/effects/ClientEffectsSystem.kt | 14 +++++------ .../cake/stages/ingame/effects/README.md | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index d5787790..c1b7ded8 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -370,7 +370,7 @@ class ClientEffectsSystem( speedMin = 0f, speedMax = 8f, spread = 0.35f, - gravity = 45f, + gravity = 0f, sizeMin = 0.28f, sizeMax = 0.75f, lifetimeMinMs = 700, @@ -391,13 +391,13 @@ class ClientEffectsSystem( resolvePaletteColor(colorIndex, fallback = Color(0.15f, 0.95f, 0.45f, 1f)) }, speedMin = 0f, - speedMax = 2f, - spread = 0.2f, + speedMax = 0f, + spread = 0f, gravity = 0f, sizeMin = 0.22f, sizeMax = 0.55f, - lifetimeMinMs = 3500, - lifetimeMaxMs = 5200, + lifetimeMinMs = 10000, + lifetimeMaxMs = 10000, ) } @@ -1033,7 +1033,7 @@ class ClientEffectsSystem( speedMin = 20f, speedMax = 220f, spread = 0.15f, - gravity = -320f, + gravity = -80f, sizeMin = 0.38f, sizeMax = 1.2f, lifetimeMinMs = 580, @@ -1122,7 +1122,7 @@ class ClientEffectsSystem( speedMin = 50f, speedMax = 113f, spread = 0.55f, - gravity = -320f, + gravity = -80f, sizeMin = 0.34f, sizeMax = 1.15f, lifetimeMinMs = 300, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 890d59ed..a350d3a7 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -166,6 +166,29 @@ Tests use synthetic messages, clocks and palette values with the real light and particle runtimes to exercise refresh, motion, sound requests, coexistence, expiry, pause, resource limits and cleanup. No installed content or window is needed. +## Impacts and Point Bursts + +`ImpactEffects` supplies particle recipes for blood, gunshot, spark, shotgun and +blaster families, including blue-hyperblaster impacts. Spawn positions scatter +along the supplied normal, while velocities scatter independently; blaster +variants also carry directed velocity and their own palette ranges. Chainfist +smoke rises without downward acceleration. The outer controller retains model +flashes and dynamic lights. + +Splash, laser, welding and tunnel effects preserve their transmitted counts, +including zero, subject to the shared particle budget. Laser/welding particles +vary within their palette range and fall; tunnel particles retain the supplied +color and accelerate upward. Gunshot/bullet impacts select a ricochet on three +of sixteen sound rolls; spark splashes weight their three sounds 1:1:2. Sounds +remain independent of whether the particle budget permits emission. + +`PointParticles` adds the boss teleport's tall radial column and tracker +explosion's black particle cloud, alongside their existing sound/light components. +Other point/trail families retain dedicated recipes: rail helix/core, green BFG +cloud, teleport bursts and outward widow splash. Ordinary bubbles rise at a +steady velocity, and diagnostic trails remain stationary. These are Cake +approximations, rather than pixel-perfect replicas of reference emission. + ## Visual Behaviour and Current Limits Particle families select colours from `q2palette.bin`. Useful compatibility anchors: From 0ff8cde41e96b47c506233b801aea11076cddbdb Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:09:23 +0100 Subject: [PATCH 20/42] Share explosion animation rules and correct particle exclusions --- .../ingame/effects/ClientEffectsSystem.kt | 32 ----------- .../ingame/effects/ExplosionAnimation.kt | 42 ++++++++++++++ .../stages/ingame/effects/PointParticles.kt | 20 ++++++- .../ingame/effects/ExplosionAnimationTest.kt | 56 +++++++++++++++++++ .../ingame/effects/PointParticlesTest.kt | 17 +++++- 5 files changed, 133 insertions(+), 34 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index c1b7ded8..1f881647 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -445,7 +445,6 @@ class ClientEffectsSystem( Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, Defines.TE_GRENADE_EXPLOSION_WATER -> { - emitLegacyExplosionPaletteParticles(position, count = 128) spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", @@ -461,7 +460,6 @@ class ClientEffectsSystem( } Defines.TE_PLASMA_EXPLOSION -> { - emitLegacyExplosionPaletteParticles(position, count = 96) spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", @@ -477,7 +475,6 @@ class ClientEffectsSystem( Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER, Defines.TE_EXPLOSION1_NP -> { - emitLegacyExplosionPaletteParticles(position, count = if (msg.style == Defines.TE_EXPLOSION1_BIG) 196 else 112) spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = if (msg.style == Defines.TE_EXPLOSION1_BIG) { @@ -521,7 +518,6 @@ class ClientEffectsSystem( } Defines.TE_PLAIN_EXPLOSION -> { - emitLegacyExplosionPaletteParticles(position, count = 96) spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", @@ -807,32 +803,6 @@ class ClientEffectsSystem( ) } - private fun emitLegacyExplosionPaletteParticles( - origin: Vector3, - count: Int, - ) { - val safeCount = count.coerceIn(1, 512) - repeat(safeCount) { - val colorIndex = EXPLOSION_PARTICLE_BASE_COLOR + Globals.rnd.nextInt(EXPLOSION_PARTICLE_VARIANTS) - particleSystem.emitBurst( - origin = origin, - direction = floatArrayOf(0f, 0f, 1f), - count = 1, - color = resolvePaletteColor(colorIndex, fallback = EXPLOSION_PARTICLE_FALLBACK), - speedMin = 60f, - speedMax = 320f, - spread = 1.2f, - gravity = -210f, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.6f, - sizeMax = 2f, - lifetimeMinMs = 240, - lifetimeMaxMs = 900, - ) - } - } - private fun emitBlasterTrail( startX: Float, startY: Float, @@ -1408,10 +1378,8 @@ private val RAIL_TRAIL_CORE_COLOR_FALLBACK = Color(0.94f, 0.94f, 0.94f, 1f) private val HEATBEAM_PARTICLE_FALLBACK = Color(1f, .7f, .2f, 1f) private val STEAM_PARTICLE_FALLBACK = Color(.75f, .75f, .75f, 1f) -private const val EXPLOSION_PARTICLE_BASE_COLOR = 0xE0 private val IMPACT_PARTICLE_COLOR_FALLBACK = Color(0.95f, 0.9f, 0.65f, 1f) -private const val EXPLOSION_PARTICLE_VARIANTS = 8 private val EXPLOSION_PARTICLE_FALLBACK = Color(1f, 0.52f, 0.22f, 1f) private data class ParticleTrailSpec( diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt new file mode 100644 index 00000000..8b0e6524 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt @@ -0,0 +1,42 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g2d.Animation + +internal data class ExplosionFrame(val previousFrame: Int, val currentFrame: Int, val interpolation: Float, + val alpha: Float, val skin: Int?, val translucent: Boolean) + +/** Shared MD2/sprite timing and presentation rules from CL_AddExplosions, using libGDX frame selection. */ +internal class ExplosionAnimation( + private val type: ExplosionType, + private val spawnTimeMs: Int, + private val frameDurationMs: Int, + private val firstFrame: Int, + private val frameCount: Int, + private val startsTranslucent: Boolean = false, + private val baseSkin: Int? = null, +) { + init { require(frameDurationMs > 0 && frameCount > 0) } + // Use frame units to avoid .1-second float rounding delaying exact millisecond frame boundaries. + private val animation = Animation(1f, *Array(frameCount) { it }) + + fun sample(nowMs: Int): ExplosionFrame? { + val age = (nowMs.toLong() - spawnTimeMs).coerceAtLeast(0) + val index = animation.getKeyFrameIndex(age.toFloat() / frameDurationMs) + if (index >= if (type == ExplosionType.FLASH) 1 else frameCount - 1) return null + val fraction = (age % frameDurationMs).toFloat() / frameDurationMs + val alpha = when (type) { + ExplosionType.MISC -> 1f - (index + fraction) / (frameCount - 1) + ExplosionType.FLASH, ExplosionType.MFLASH -> 1f + ExplosionType.POLY -> (16f - index) / 16f + ExplosionType.POLY2 -> (5f - index) / 5f + }.coerceIn(0f, 1f) + val skin = when (type) { + ExplosionType.POLY -> if (index < 10) index / 2 else if (index < 13) 5 else 6 + ExplosionType.POLY2 -> 0 + else -> baseSkin + } + val translucent = startsTranslucent || type == ExplosionType.POLY2 || (type == ExplosionType.POLY && index >= 10) + return ExplosionFrame(firstFrame + index, firstFrame + (index + 1).coerceAtMost(frameCount - 1), + fraction, alpha, skin, translucent) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt index 1539157e..1025a933 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt @@ -7,7 +7,7 @@ import jake2.qcommon.Defines import jake2.qcommon.network.messages.server.PointTEMessage import kotlin.random.Random -/** Particle components of CL_BigTeleportParticles and CL_ColorExplosionParticles, backed by libGDX. */ +/** Particle components of CL_BigTeleportParticles, CL_ColorExplosionParticles and CL_ExplosionParticles, backed by libGDX. */ internal class PointParticles( private val particles: GdxParticleRuntime, private val paletteColor: (Int) -> Color, @@ -19,6 +19,24 @@ internal class PointParticles( private val teleportPalette = intArrayOf(16, 104, 168, 144) fun process(message: PointTEMessage, nowMs: Int): Boolean { + val explosionCount = when (message.style) { + Defines.TE_EXPLOSION1_BIG, Defines.TE_EXPLOSION1_NP, Defines.TE_PLAIN_EXPLOSION -> 0 + Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, Defines.TE_GRENADE_EXPLOSION_WATER -> 128 + Defines.TE_EXPLOSION1, Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER -> 112 + Defines.TE_PLASMA_EXPLOSION -> 96 + else -> null + } + if (explosionCount != null) { + repeat(explosionCount) { + position.set(message.position.x + signedRandom() * 16f, message.position.y + signedRandom() * 16f, + message.position.z + signedRandom() * 16f) + velocity.set(signedRandom() * 192f, signedRandom() * 192f, signedRandom() * 192f) + acceleration.set(0f, 0f, -80f) + if (!particles.emit(position, velocity, acceleration, paletteColor(0xE0 + random.nextInt(8)), + size = .6f, lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return true + } + return true + } val teleport = when (message.style) { Defines.TE_BOSSTPORT -> true Defines.TE_TRACKER_EXPLOSION -> false diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt new file mode 100644 index 00000000..f4e55622 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt @@ -0,0 +1,56 @@ +package org.demoth.cake.stages.ingame.effects + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ExplosionAnimationTest { + @Test fun `model frame pairs interpolate relative to the selected base and expire at the last transition`() { + val animation = ExplosionAnimation(ExplosionType.POLY, 1000, 100, 30, 19) + val first = animation.sample(1000)!! + assertEquals(30, first.previousFrame); assertEquals(31, first.currentFrame) + assertEquals(.5f, animation.sample(1050)!!.interpolation) + assertEquals(31, animation.sample(1100)!!.previousFrame) + assertNotNull(animation.sample(2799)) + assertNull(animation.sample(2800)) + } + + @Test fun `polygon explosion alpha skin and translucency follow the animation together`() { + val animation = ExplosionAnimation(ExplosionType.POLY, 0, 100, 0, 15) + assertEquals(0, animation.sample(0)!!.skin) + assertEquals(.5f, animation.sample(800)!!.alpha) + assertEquals(4, animation.sample(800)!!.skin) + assertFalse(animation.sample(800)!!.translucent) + assertEquals(5, animation.sample(1000)!!.skin) + assertTrue(animation.sample(1000)!!.translucent) + assertEquals(6, animation.sample(1300)!!.skin) + assertNull(animation.sample(1400)) + } + + @Test fun `misc effects fade smoothly and preserve their supplied skin and translucency`() { + val animation = ExplosionAnimation(ExplosionType.MISC, 0, 100, 0, 4, true, 2) + assertEquals(.5f, animation.sample(150)!!.alpha) + assertEquals(2, animation.sample(150)!!.skin) + assertTrue(animation.sample(150)!!.translucent) + assertNull(animation.sample(300)) + } + + @Test fun `flash muzzle flash and second polygon style retain their distinct lifetime and alpha rules`() { + val flash = ExplosionAnimation(ExplosionType.FLASH, 0, 100, 0, 2) + assertEquals(1f, flash.sample(99)!!.alpha) + assertNull(flash.sample(100)) + val muzzle = ExplosionAnimation(ExplosionType.MFLASH, 0, 100, 0, 4) + assertEquals(1f, muzzle.sample(200)!!.alpha) + assertNull(muzzle.sample(300)) + val second = ExplosionAnimation(ExplosionType.POLY2, 0, 100, 0, 5) + assertEquals(.6f, second.sample(200)!!.alpha) + assertEquals(0, second.sample(200)!!.skin) + assertTrue(second.sample(200)!!.translucent) + } + + @Test fun `paused clocks and equivalent update paths yield the same frame without advancing state`() { + val animation = ExplosionAnimation(ExplosionType.MISC, 0, 100, 0, 4) + val frame = animation.sample(150) + repeat(100) { assertEquals(frame, animation.sample(150)) } + assertEquals(animation.sample(0), animation.sample(-100)) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt index 57992549..e78cec49 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt @@ -48,6 +48,21 @@ class PointParticlesTest { } } + @Test fun `big no-particle and plain explosions omit particles while other explosions retain them`() { + for (style in listOf(Defines.TE_EXPLOSION1_BIG, Defines.TE_EXPLOSION1_NP, Defines.TE_PLAIN_EXPLOSION)) Fixture().use { + assertTrue(it.particles.process(PointTEMessage(style, Vector3f.zero), 0)) + assertEquals(0, it.runtime.liveCount) + assertTrue(it.colors.isEmpty()) + } + for (style in listOf(Defines.TE_EXPLOSION1, Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, + Defines.TE_GRENADE_EXPLOSION_WATER, Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER, + Defines.TE_PLASMA_EXPLOSION)) Fixture().use { + assertTrue(it.particles.process(PointTEMessage(style, Vector3f.zero), 0)) + assertTrue(it.runtime.liveCount > 0) + assertTrue(it.colors.all { color -> color in 0xE0..0xE7 }) + } + } + @Test fun `burst work respects disabled and exhausted particle budgets and ignores unrelated point effects`() { for (style in listOf(Defines.TE_BOSSTPORT, Defines.TE_TRACKER_EXPLOSION)) { Fixture(3).use { fixture -> @@ -62,7 +77,7 @@ class PointParticlesTest { } } Fixture().use { fixture -> - assertFalse(fixture.particles.process(PointTEMessage(Defines.TE_EXPLOSION1, Vector3f.zero), 0)) + assertFalse(fixture.particles.process(PointTEMessage(Defines.TE_BFG_EXPLOSION, Vector3f.zero), 0)) assertEquals(0, fixture.runtime.liveCount) } } From 688934d30117617f58df032da00401b2ec1a83b7 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:13:21 +0100 Subject: [PATCH 21/42] Drive explosion materials and frame lights from shared animation state --- .../ingame/WorldPresentationRenderer.kt | 1 + .../ingame/effects/ClientEffectsSystem.kt | 47 ++-- .../ingame/effects/ClientTransientEffect.kt | 4 + .../ingame/effects/ExplosionAnimation.kt | 5 +- .../stages/ingame/effects/ExplosionEffect.kt | 33 +++ .../ingame/effects/ExplosionMd2Effect.kt | 201 +++--------------- .../ingame/effects/ExplosionAnimationTest.kt | 3 + .../ingame/effects/ExplosionEffectTest.kt | 81 +++++++ 8 files changed, 191 insertions(+), 184 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt index 44667bd7..1847bd74 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt @@ -126,6 +126,7 @@ internal class WorldPresentationRenderer { context.effectsSystem.update(context.delta, context.entityManager.currentFrame.serverframe) context.replicatedEntityEffectCollector.collectTrails(lerpFrac) context.dynamicLightSystem.beginFrame(context.currentTimeMs, context.delta) + context.effectsSystem.collectDynamicLights() context.replicatedEntityEffectCollector.collectDynamicLights( lerpFrac = lerpFrac, currentTimeMs = context.currentTimeMs, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 1f881647..a0622a15 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -235,6 +235,12 @@ class ClientEffectsSystem( } } + /** Adds animation-owned lights after the light system's frame reset. */ + fun collectDynamicLights() { + val lights = dynamicLightSystem ?: return + activeEffects.forEach { it.collectDynamicLights(lights) } + } + /** * Renders model/sprite transient effects via `ModelBatch`. * @@ -336,15 +342,12 @@ class ClientEffectsSystem( Defines.TE_GUNSHOT, Defines.TE_BULLET_SPARKS, Defines.TE_SHOTGUN -> spawnSmokeAndFlash(position) Defines.TE_BLASTER -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 0) - spawnDynamicLight(position, 150f, 1f, 1f, 0f) } Defines.TE_BLASTER2 -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 1) - spawnDynamicLight(position, 150f, 0f, 1f, 0f) } Defines.TE_FLECHETTE -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 2) - spawnDynamicLight(position, 150f, 0.19f, 0.41f, 0.75f) } } } @@ -430,8 +433,8 @@ class ClientEffectsSystem( position = position, firstFrame = 0, frameCount = 2, + light = ExplosionLight((100 + Globals.rnd.nextInt(75)).toFloat(), Vector3f(1f, 1f, .3f)), ) - spawnDynamicLight(position, (100 + Globals.rnd.nextInt(75)).toFloat(), 1f, 1f, 0.3f) } } @@ -445,7 +448,6 @@ class ClientEffectsSystem( Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, Defines.TE_GRENADE_EXPLOSION_WATER -> { - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -460,7 +462,6 @@ class ClientEffectsSystem( } Defines.TE_PLASMA_EXPLOSION -> { - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -475,7 +476,6 @@ class ClientEffectsSystem( Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER, Defines.TE_EXPLOSION1_NP -> { - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = if (msg.style == Defines.TE_EXPLOSION1_BIG) { "models/objects/r_explode2/tris.md2" @@ -494,14 +494,14 @@ class ClientEffectsSystem( } Defines.TE_BFG_EXPLOSION -> { - spawnDynamicLight(position, 350f, 0f, 1f, 0f) spawnExplosionSpriteEffect( type = ExplosionType.POLY, spritePath = "sprites/s_bfg2.sp2", position = position, - firstFrame = 1, + firstFrame = 0, frameCount = 4, startsTranslucent = true, + light = ExplosionLight(350f, Vector3f(0f, 1f, 0f)), ) } @@ -518,7 +518,6 @@ class ClientEffectsSystem( } Defines.TE_PLAIN_EXPLOSION -> { - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -601,8 +600,13 @@ class ClientEffectsSystem( pitchDeg = pitch, yawDeg = yaw, skinIndex = skinIndex, - startsTranslucent = true, - fullBright = true, + startsTranslucent = skinIndex != 0, + fullBright = skinIndex != 0, + light = ExplosionLight(150f, when (skinIndex) { + 0 -> Vector3f(1f, 1f, 0f) + 1 -> Vector3f(0f, 1f, 0f) + else -> Vector3f(.19f, .41f, .75f) + }), ) } @@ -620,6 +624,7 @@ class ClientEffectsSystem( frameCount = frameCount, yawDeg = Globals.rnd.nextInt(360).toFloat(), fullBright = true, + light = ExplosionLight(350f, Vector3f(1f, .5f, .5f)), ) } @@ -635,8 +640,14 @@ class ClientEffectsSystem( skinIndex: Int? = null, startsTranslucent: Boolean = false, fullBright: Boolean = false, + light: ExplosionLight? = null, ) { - val md2 = assetCatalog.getModel(modelPath) ?: return + val md2 = assetCatalog.getModel(modelPath) + if (md2 == null) { + if (light != null) activeEffects += ExplosionEffect(type, currentTimeMsProvider(), 100, firstFrame, + frameCount, position, startsTranslucent, skinIndex, light) + return + } val instance = createModelInstance(md2.model) (instance.userData as? Md2CustomData)?.let { userData -> if (skinIndex != null) { @@ -665,6 +676,7 @@ class ClientEffectsSystem( baseSkinIndex = skinIndex, fullBright = fullBright, startsTranslucent = startsTranslucent, + light = light, ) } @@ -675,8 +687,14 @@ class ClientEffectsSystem( firstFrame: Int, frameCount: Int, startsTranslucent: Boolean = false, + light: ExplosionLight? = null, ) { - val sprite = assetCatalog.getSprite(spritePath) ?: return + val sprite = assetCatalog.getSprite(spritePath) + if (sprite == null) { + if (light != null) activeEffects += ExplosionEffect(type, currentTimeMsProvider(), 100, firstFrame, + frameCount, position, startsTranslucent, light = light) + return + } activeEffects += ExplosionSpriteEffect( spriteRenderer = spriteRenderer, cameraProvider = cameraProvider, @@ -688,6 +706,7 @@ class ClientEffectsSystem( frameCount = frameCount, position = Vector3(position), startsTranslucent = startsTranslucent, + light = light, ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt index 63f406dc..a25f84d8 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt @@ -2,6 +2,7 @@ package org.demoth.cake.stages.ingame.effects import com.badlogic.gdx.graphics.g3d.ModelBatch import com.badlogic.gdx.utils.Disposable +import org.demoth.cake.stages.ingame.DynamicLightSystem /** * Runtime effect instance owned by [ClientEffectsSystem]. @@ -26,6 +27,9 @@ interface ClientTransientEffect : Disposable { */ fun render(modelBatch: ModelBatch) + /** Collect frame-local lights after the light system's frame reset and before drawing. */ + fun collectDynamicLights(lights: DynamicLightSystem) = Unit + override fun dispose() { // no-op by default } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt index 8b0e6524..b826a8b5 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt @@ -21,8 +21,9 @@ internal class ExplosionAnimation( fun sample(nowMs: Int): ExplosionFrame? { val age = (nowMs.toLong() - spawnTimeMs).coerceAtLeast(0) - val index = animation.getKeyFrameIndex(age.toFloat() / frameDurationMs) - if (index >= if (type == ExplosionType.FLASH) 1 else frameCount - 1) return null + val elapsedFrames = age.toFloat() / frameDurationMs + if (elapsedFrames >= if (type == ExplosionType.FLASH) 1 else frameCount - 1) return null + val index = animation.getKeyFrameIndex(elapsedFrames) val fraction = (age % frameDurationMs).toFloat() / frameDurationMs val alpha = when (type) { ExplosionType.MISC -> 1f - (index + fraction) / (frameCount - 1) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt new file mode 100644 index 00000000..e0ed7d31 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt @@ -0,0 +1,33 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.ModelBatch +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.math.Vector3f +import org.demoth.cake.stages.ingame.DynamicLightSystem + +internal data class ExplosionLight(val radius: Float, val color: Vector3f) + +/** Shared animation/light owner; also preserves an explosion's light when optional visual assets are absent. */ +internal open class ExplosionEffect( + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, + position: Vector3, startsTranslucent: Boolean = false, baseSkin: Int? = null, + private val light: ExplosionLight? = null, +) : ClientTransientEffect { + protected val position = Vector3(position) + private val animation = ExplosionAnimation(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, startsTranslucent, baseSkin) + protected var frame: ExplosionFrame? = null + private set + + override fun update(nowMs: Int, deltaSeconds: Float): Boolean { + frame = animation.sample(nowMs) + return frame != null + } + + override fun collectDynamicLights(lights: DynamicLightSystem) { + val light = light ?: return + val frame = frame ?: return + lights.addFrameLight(position, light.radius * frame.alpha, light.color.x, light.color.y, light.color.z) + } + + override fun render(modelBatch: ModelBatch) = Unit +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt index 611e69c7..21c33c0c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt @@ -10,7 +10,6 @@ import org.demoth.cake.applyModelOpacity import org.demoth.cake.assets.md2.Md2CustomData import org.demoth.cake.assets.Sp2Asset import org.demoth.cake.assets.Sp2Renderer -import kotlin.math.floor /** * Legacy/Yamagi temp-explosion renderer behavior with enum-based type semantics. @@ -27,122 +26,45 @@ enum class ExplosionType { POLY2, } -class ExplosionMd2Effect( +internal class ExplosionMd2Effect( private val modelInstance: ModelInstance, - private val type: ExplosionType, - private val spawnTimeMs: Int, - private val frameDurationMs: Int, - private val firstFrame: Int, - private val frameCount: Int, - private val position: Vector3, + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, + position: Vector3, private val pitchDeg: Float = 0f, private val yawDeg: Float = 0f, private val rollDeg: Float = 0f, - private val baseSkinIndex: Int? = null, + baseSkinIndex: Int? = null, private val fullBright: Boolean = false, - private val startsTranslucent: Boolean = false, -) : ClientTransientEffect { + startsTranslucent: Boolean = false, + light: ExplosionLight? = null, +) : ExplosionEffect(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, position, startsTranslucent, baseSkinIndex, light) { override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - val elapsedMs = nowMs - spawnTimeMs - if (elapsedMs < 0) { - return true - } - - val durationMs = frameDurationMs.coerceAtLeast(1) - val frameFloat = elapsedMs.toFloat() / durationMs.toFloat() - val frameIndex = floor(frameFloat).toInt() - val nextFrameIndex = (frameIndex + 1).coerceAtMost(frameCount - 1) - val interpolation = (frameFloat - floor(frameFloat)).coerceIn(0f, 1f) - - var skinIndex = baseSkinIndex - var forceTranslucent = startsTranslucent - val rawAlpha = when (type) { - ExplosionType.MISC -> { - if (frameIndex >= frameCount - 1) { - return false - } - val denom = (frameCount - 1).coerceAtLeast(1).toFloat() - 1f - (frameFloat / denom) - } - - ExplosionType.FLASH -> { - if (frameIndex >= 1) { - return false - } - 1f - } - - ExplosionType.MFLASH -> { - if (frameIndex >= frameCount - 1) { - return false - } - 1f - } - - ExplosionType.POLY -> { - if (frameIndex >= frameCount - 1) { - return false - } - val alpha = (16f - frameIndex.toFloat()) / 16f - if (frameIndex < 10) { - skinIndex = (frameIndex shr 1).coerceAtLeast(0) - } else { - forceTranslucent = true - skinIndex = if (frameIndex < 13) 5 else 6 - } - alpha - } - - ExplosionType.POLY2 -> { - if (frameIndex >= frameCount - 1) { - return false - } - forceTranslucent = true - skinIndex = 0 - (5f - frameIndex.toFloat()) / 5f - } - }.coerceIn(0f, 1f) - + if (!super.update(nowMs, deltaSeconds)) return false + val frame = checkNotNull(frame) (modelInstance.userData as? Md2CustomData)?.let { userData -> - userData.frame1 = firstFrame + frameIndex.coerceAtMost(frameCount - 1) - userData.frame2 = firstFrame + nextFrameIndex - userData.interpolation = interpolation - if (skinIndex != null) { - userData.skinIndex = skinIndex - } + userData.frame1 = frame.previousFrame + userData.frame2 = frame.currentFrame + userData.interpolation = frame.interpolation + frame.skin?.let { userData.skinIndex = it } if (fullBright) { // Fullbright path intentionally bypasses directional alias shading. - userData.lightRed = 1f - userData.lightGreen = 1f - userData.lightBlue = 1f - userData.shadeVectorX = 0f - userData.shadeVectorY = 0f - userData.shadeVectorZ = 0f + userData.lightRed = 1f; userData.lightGreen = 1f; userData.lightBlue = 1f + userData.shadeVectorX = 0f; userData.shadeVectorY = 0f; userData.shadeVectorZ = 0f } } - modelInstance.transform.idt() - if (yawDeg != 0f) { - modelInstance.transform.rotate(Vector3.Z, yawDeg) - } - if (pitchDeg != 0f) { - modelInstance.transform.rotate(Vector3.Y, pitchDeg) - } - if (rollDeg != 0f) { - modelInstance.transform.rotate(Vector3.X, -rollDeg) - } + if (yawDeg != 0f) modelInstance.transform.rotate(Vector3.Z, yawDeg) + if (pitchDeg != 0f) modelInstance.transform.rotate(Vector3.Y, pitchDeg) + if (rollDeg != 0f) modelInstance.transform.rotate(Vector3.X, -rollDeg) modelInstance.transform.setTranslation(position) - // Alpha only affects legacy opaque explosions after they become translucent. - // Yamagi GL3 alias path scales translucent model alpha by 0.666. - val effectiveAlpha = if (forceTranslucent) rawAlpha * 0.666f else 1f - applyModelOpacity(modelInstance, effectiveAlpha.coerceIn(0f, 1f), forceTranslucent = forceTranslucent) + // Alpha affects opaque explosions only once they become translucent. + // Yamagi's alias renderer additionally scales alpha by 0.666; libGDX materials use the animation alpha directly. + applyModelOpacity(modelInstance, if (frame.translucent) frame.alpha else 1f, forceTranslucent = frame.translucent) return true } - override fun render(modelBatch: ModelBatch) { - modelBatch.render(modelInstance) - } + override fun render(modelBatch: ModelBatch) { modelBatch.render(modelInstance) } } /** @@ -152,18 +74,15 @@ class ExplosionMd2Effect( * - Jake2: `client/CL_tent.AddExplosions` (`ex_poly` on `sprites/s_bfg2.sp2`). * - Yamagi: `cl_tempentities.c` (`CL_AddExplosions`). */ -class ExplosionSpriteEffect( +internal class ExplosionSpriteEffect( private val spriteRenderer: Sp2Renderer, private val cameraProvider: () -> Camera, private val sprite: Sp2Asset, - private val type: ExplosionType, - private val spawnTimeMs: Int, - private val frameDurationMs: Int, - private val firstFrame: Int, - private val frameCount: Int, + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, position: Vector3, - private val startsTranslucent: Boolean = false, -) : ClientTransientEffect { + startsTranslucent: Boolean = false, + light: ExplosionLight? = null, +) : ExplosionEffect(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, position, startsTranslucent, light = light) { private val entity = ClientEntity("sp2_explosion").apply { spriteAsset = sprite prev.origin = floatArrayOf(position.x, position.y, position.z) @@ -174,67 +93,13 @@ class ExplosionSpriteEffect( } override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - if (frameCount <= 0 || frameDurationMs <= 0 || sprite.frames.isEmpty()) { - return false - } - val elapsedMs = nowMs - spawnTimeMs - if (elapsedMs < 0) { - return true - } - - val frameFloat = elapsedMs.toFloat() / frameDurationMs.toFloat() - val frameIndex = floor(frameFloat).toInt() - - var forceTranslucent = startsTranslucent - val rawAlpha = when (type) { - ExplosionType.MISC -> { - if (frameIndex >= frameCount - 1) { - return false - } - val denom = (frameCount - 1).coerceAtLeast(1).toFloat() - 1f - (frameFloat / denom) - } - - ExplosionType.FLASH -> { - if (frameIndex >= 1) { - return false - } - 1f - } - - ExplosionType.MFLASH -> { - if (frameIndex >= frameCount - 1) { - return false - } - 1f - } - - ExplosionType.POLY -> { - if (frameIndex >= frameCount - 1) { - return false - } - if (frameIndex >= 10) { - forceTranslucent = true - } - (16f - frameIndex.toFloat()) / 16f - } - - ExplosionType.POLY2 -> { - if (frameIndex >= frameCount - 1) { - return false - } - forceTranslucent = true - (5f - frameIndex.toFloat()) / 5f - } - }.coerceIn(0f, 1f) - - entity.resolvedFrame = firstFrame + frameIndex.coerceAtMost(frameCount - 1) - entity.resolvedRenderFx = if (forceTranslucent) Defines.RF_TRANSLUCENT else 0 - entity.alpha = if (forceTranslucent) rawAlpha else 1f + if (!super.update(nowMs, deltaSeconds) || sprite.frames.isEmpty()) return false + val frame = checkNotNull(frame) + entity.resolvedFrame = frame.currentFrame + entity.resolvedRenderFx = if (frame.translucent) Defines.RF_TRANSLUCENT else 0 + entity.alpha = if (frame.translucent) frame.alpha else 1f return true } - override fun render(modelBatch: ModelBatch) { - spriteRenderer.render(modelBatch, entity, cameraProvider(), 1f) - } + override fun render(modelBatch: ModelBatch) { spriteRenderer.render(modelBatch, entity, cameraProvider(), 1f) } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt index f4e55622..1573158c 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt @@ -38,6 +38,9 @@ class ExplosionAnimationTest { val flash = ExplosionAnimation(ExplosionType.FLASH, 0, 100, 0, 2) assertEquals(1f, flash.sample(99)!!.alpha) assertNull(flash.sample(100)) + val oneFrameFlash = ExplosionAnimation(ExplosionType.FLASH, 0, 100, 0, 1) + assertNotNull(oneFrameFlash.sample(99)) + assertNull(oneFrameFlash.sample(100)) val muzzle = ExplosionAnimation(ExplosionType.MFLASH, 0, 100, 0, 4) assertEquals(1f, muzzle.sample(200)!!.alpha) assertNull(muzzle.sample(300)) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt new file mode 100644 index 00000000..b3cc226a --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt @@ -0,0 +1,81 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Material +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.math.Vector3f +import org.demoth.cake.assets.md2.Md2CustomData +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class ExplosionEffectTest { + @Test fun `lights fade with animation remain stable on pause and disappear when animation ends even without models`() { + val lights = DynamicLightSystem() + val origin = Vector3(10f, 20f, 30f) + val effect = ExplosionEffect(ExplosionType.MISC, 0, 100, 0, 4, origin, + light = ExplosionLight(150f, Vector3f(.2f, .4f, .8f))) + origin.setZero() + fun radius(now: Int): Float? { + effect.update(now, .01f) + lights.beginFrame(now, .01f) + effect.collectDynamicLights(lights) + val result = lights.visibleLightsForShader().singleOrNull() + if (result != null) { + assertEquals(Vector3(10f, 20f, 30f), result.origin) + assertEquals(.2f, result.red); assertEquals(.4f, result.green); assertEquals(.8f, result.blue) + } + return result?.radius + } + assertEquals(150f, radius(0)) + assertEquals(75f, radius(150)) + repeat(10) { assertEquals(75f, radius(150)) } + assertNull(radius(300)) + } + + @Test fun `MD2 frames skin interpolation and stock material opacity use the same animation as the light`() { + val model = Model() + try { + val instance = ModelInstance(model).apply { + materials.add(Material()) + userData = Md2CustomData.empty() + } + val effect = ExplosionMd2Effect(instance, ExplosionType.MISC, 0, 100, 0, 4, Vector3(1f, 2f, 3f), + baseSkinIndex = 2, startsTranslucent = true, light = ExplosionLight(150f, Vector3f.one)) + assertTrue(effect.update(150, .1f)) + val data = instance.userData as Md2CustomData + assertEquals(1, data.frame1); assertEquals(2, data.frame2); assertEquals(.5f, data.interpolation) + assertEquals(2, data.skinIndex) + assertEquals(.5f, (instance.materials.first().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + assertEquals(Vector3(1f, 2f, 3f), instance.transform.getTranslation(Vector3())) + val lights = DynamicLightSystem() + lights.beginFrame(150, 0f) + effect.collectDynamicLights(lights) + assertEquals(75f, lights.visibleLightsForShader().single().radius) + assertFalse(effect.update(300, .1f)) + } finally { model.dispose() } + } + + @Test fun `opaque polygon models become translucent with the selected late skins and alpha`() { + val model = Model() + try { + val instance = ModelInstance(model).apply { materials.add(Material()); userData = Md2CustomData.empty() } + val effect = ExplosionMd2Effect(instance, ExplosionType.POLY, 0, 100, 0, 15, Vector3.Zero, fullBright = true) + effect.update(800, 0f) + assertFalse(instance.materials.first().has(BlendingAttribute.Type)) + assertEquals(4, (instance.userData as Md2CustomData).skinIndex) + effect.update(1300, 0f) + assertEquals(6, (instance.userData as Md2CustomData).skinIndex) + assertEquals(3f / 16f, (instance.materials.first().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + } finally { model.dispose() } + } + + companion object { + @BeforeAll @JvmStatic fun setup() { GdxNativesLoader.load(); RenderTuningCvars.register() } + } +} From 7069d82a15ee166c5194ce92a38591ffde411514 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:19:02 +0100 Subject: [PATCH 22/42] Render bounded BFG laser pulses through shared beam geometry --- .../org/demoth/cake/assets/BeamRenderer.kt | 165 +++++++----------- .../ingame/WorldPresentationRenderer.kt | 1 + .../ingame/effects/ClientEffectsSystem.kt | 26 ++- .../stages/ingame/effects/LaserEffects.kt | 35 ++++ .../stages/ingame/effects/LineBeamEffect.kt | 84 --------- .../cake/stages/ingame/effects/README.md | 36 +++- .../demoth/cake/assets/BeamRendererTest.kt | 57 ++++++ .../stages/ingame/effects/LaserEffectsTest.kt | 48 +++++ 8 files changed, 248 insertions(+), 204 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt delete mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt index 9b114361..87277e18 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt @@ -18,63 +18,78 @@ import com.badlogic.gdx.math.Vector3 import com.badlogic.gdx.utils.Disposable import jake2.qcommon.Defines import jake2.qcommon.Globals +import jake2.qcommon.math.Vector3f import org.demoth.cake.ClientEntity -import kotlin.collections.plusAssign /** - * Isolated renderer for RF_BEAM entities. Keeps beam-specific caches and temporary vectors out - * of [org.demoth.cake.stages.ingame.Game3dScreen]. + * Isolated renderer for RF_BEAM entities and temporary palette lasers. Keeps beam-specific caches + * and temporary vectors out of [org.demoth.cake.stages.ingame.Game3dScreen]. + * Shared libGDX cylinder models are owned here and disposed with the screen, rather than per laser. */ -class BeamRenderer( - private val assetManager: AssetManager +class BeamRenderer internal constructor( + private val paletteColor: (Int) -> Color, + private val createModel: (Material) -> Model, ) : Disposable { - // Cached by (paletteIndex + translucency) to avoid rebuilding identical beam meshes each frame. - private val beamRenderables = mutableMapOf() - // Quake 2 global palette in RGBA8888 format, loaded from q2palette.bin. - private val q2Palette: IntArray by lazy { assetManager.get("q2palette.bin", Any::class.java) as IntArray } - // Temporary vectors/quaternion reused each frame to avoid allocations in beam rendering. - private val beamStart = Vector3() - private val beamEnd = Vector3() - private val beamDirection = Vector3() - private val beamMidpoint = Vector3() - private val beamScale = Vector3() - private val beamRotation = Quaternion() + constructor(assetManager: AssetManager) : this( + paletteColor = { index -> + val palette = assetManager.get("q2palette.bin", Any::class.java) as IntArray + // RGBA8888 palette bytes yield the same RGB as the reference d_8to24table beam path. + Color(palette[index and 255]).apply { a = 1f } + }, + createModel = ::createCylinder, + ) + + private data class Style(val paletteIndex: Int, val alpha: Float) + /** Reusable model and instance for one palette/opacity style. */ + private data class BeamRenderable(val model: Model, val instance: ModelInstance) + private val beamRenderables = mutableMapOf() + private val direction = Vector3() + private val midpoint = Vector3() + private val scale = Vector3() + private val rotation = Quaternion() /** - * Renders a Quake-style beam entity (RF_BEAM) as procedural cylinder geometry. - * - * Beam semantics are taken from network entity state: - * - start point = `origin` - * - end point = `old_origin` - * - diameter = `frame` - * - color bytes = packed in `skinnum` (legacy beam color hack) + * Beam semantics from network entity state: + * origin/old_origin are endpoints, frame is diameter, and skinnum packs four palette colors. + * These are the legacy RF_BEAM conventions consumed by CL_ents.AddPacketEntities. */ fun render(modelBatch: ModelBatch, entity: ClientEntity) { val state = entity.current - beamStart.set(state.origin[0], state.origin[1], state.origin[2]) - beamEnd.set(state.old_origin[0], state.old_origin[1], state.old_origin[2]) - beamDirection.set(beamEnd).sub(beamStart) - val length = beamDirection.len() - if (length <= 0.001f) { - return - } - - beamDirection.scl(1f / length) - beamRotation.setFromCross(Vector3.Y, beamDirection) + val colorShift = Globals.rnd.nextInt(4) * 8 + val paletteIndex = (state.skinnum ushr colorShift) and 255 + val alpha = if (state.renderfx and Defines.RF_TRANSLUCENT != 0) .3f else 1f + prepare(state.origin[0], state.origin[1], state.origin[2], state.old_origin[0], state.old_origin[1], state.old_origin[2], + paletteIndex, state.frame.coerceAtLeast(1).toFloat(), alpha)?.let { modelBatch.render(it) } + } - // In Quake 2, RF_BEAM frame stores the beam diameter. - val radius = state.frame.coerceAtLeast(1) * 0.5f - beamMidpoint.set(beamStart).add(beamEnd).scl(0.5f) - beamScale.set(radius, length, radius) + fun render(modelBatch: ModelBatch, start: Vector3f, end: Vector3f, paletteIndex: Int, diameter: Float, alpha: Float) { + prepare(start, end, paletteIndex, diameter, alpha)?.let { modelBatch.render(it) } + } - // select one of 4 packed beam color bytes at random each draw. - val colorShift = Globals.rnd.nextInt(4) * 8 - val paletteIndex = (state.skinnum ushr colorShift) and 0xFF - val translucent = (state.renderfx and Defines.RF_TRANSLUCENT) != 0 - val beamRenderable = getOrCreateBeamRenderable(paletteIndex, translucent) + /** Returned instances are borrowed: ModelBatch snapshots their transform when submitted. */ + internal fun prepare(start: Vector3f, end: Vector3f, paletteIndex: Int, diameter: Float, alpha: Float): ModelInstance? = + prepare(start.x, start.y, start.z, end.x, end.y, end.z, paletteIndex, diameter, alpha) - beamRenderable.instance.transform.set(beamMidpoint, beamRotation, beamScale) - modelBatch.render(beamRenderable.instance) + private fun prepare(sx: Float, sy: Float, sz: Float, ex: Float, ey: Float, ez: Float, + paletteIndex: Int, diameter: Float, alpha: Float): ModelInstance? { + direction.set(ex - sx, ey - sy, ez - sz) + val length = direction.len() + if (length <= .001f || diameter <= 0f || alpha <= 0f) return null + direction.scl(1f / length) + rotation.setFromCross(Vector3.Y, direction) + midpoint.set((sx + ex) * .5f, (sy + ey) * .5f, (sz + ez) * .5f) + scale.set(diameter * .5f, length, diameter * .5f) + val renderable = beamRenderables.getOrPut(Style(paletteIndex and 255, alpha.coerceAtMost(1f))) { + val color = paletteColor(paletteIndex and 255) + val material = Material(ColorAttribute.createDiffuse(color), + ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), + DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), IntAttribute.createCullFace(GL20.GL_NONE)) + if (alpha < 1f) material.set(BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha)) + val model = createModel(material) + BeamRenderable(model, ModelInstance(model)) + } + renderable.instance.transform.set(midpoint, rotation, scale) + return renderable.instance } override fun dispose() { @@ -82,59 +97,13 @@ class BeamRenderer( beamRenderables.clear() } - /** - * Returns a cached beam renderable for a color/translucency pair, creating it on first use. - * - * The base mesh is a unit cylinder aligned with Y axis: - * radius=1, height=1. - * It is transformed per-entity in [render]. - */ - @Suppress("DEPRECATION") - private fun getOrCreateBeamRenderable(paletteIndex: Int, translucent: Boolean): BeamRenderable { - // pack translucency in bit 8, palette index in bits [0..7] - val key = paletteIndex or (if (translucent) 1 shl 8 else 0) - return beamRenderables.getOrPut(key) { - val rgba8888 = q2Palette[paletteIndex] - // Match legacy d_8to24table RGB extraction layout used by beam renderers. - val legacyD8To24 = - ((rgba8888 ushr 24) and 0xFF) or - (((rgba8888 ushr 16) and 0xFF) shl 8) or - (((rgba8888 ushr 8) and 0xFF) shl 16) - val color = Color( - (legacyD8To24 and 0xFF) / 255f, - ((legacyD8To24 ushr 8) and 0xFF) / 255f, - ((legacyD8To24 ushr 16) and 0xFF) / 255f, - 1f - ) - val alpha = if (translucent) 0.3f else 1f - val materialAttributes = mutableListOf( - ColorAttribute.createDiffuse(color), - ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), - DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), - IntAttribute.createCullFace(GL20.GL_NONE), - ) - if (translucent) { - materialAttributes += BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha) - } - val material = Material(*materialAttributes.toTypedArray()) - val modelBuilder = ModelBuilder() - modelBuilder.begin() - modelBuilder.part( - "beam", - GL20.GL_TRIANGLES, - (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), - material - ).cylinder(2f, 1f, 2f, 6, 0f, 360f) - val model = modelBuilder.end() - BeamRenderable(model, ModelInstance(model)) - } + companion object { + /** Unit-radius, unit-height Y cylinder, transformed per draw using libGDX geometry and matrices. */ + @Suppress("DEPRECATION") + private fun createCylinder(material: Material): Model = ModelBuilder().apply { + begin() + part("beam", GL20.GL_TRIANGLES, (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), material) + .cylinder(2f, 1f, 2f, 6, 0f, 360f) + }.end() } - - /** - * Pair of reusable model + instance used to render beams of one visual style. - */ - private data class BeamRenderable( - val model: Model, - val instance: ModelInstance - ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt index 1847bd74..3cc8a34d 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt @@ -227,6 +227,7 @@ internal class WorldPresentationRenderer { context.modelBatch.begin(context.camera) try { context.effectsSystem.render(context.modelBatch) + context.effectsSystem.renderLasers(context.modelBatch, context.beamRenderer) } finally { context.modelBatch.end() } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index a0622a15..e9a4fe43 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -30,6 +30,7 @@ import org.demoth.cake.audio.CakeAudioSystem import org.demoth.cake.audio.SoundPlaybackRequest import org.demoth.cake.assets.md2.Md2CustomData import org.demoth.cake.assets.Sp2Renderer +import org.demoth.cake.assets.BeamRenderer import org.demoth.cake.createModelInstance import org.demoth.cake.stages.ingame.ClientEntityManager import org.demoth.cake.stages.ingame.DynamicLightSystem @@ -112,6 +113,7 @@ class ClientEffectsSystem( private val impactEffects = ImpactEffects(particleSystem.runtime, paletteColor = { index -> resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) }, playSound = { request -> playEffectSound(request.path, toVector3(request.origin), attenuation = request.attenuation) }) + private val laserEffects = LaserEffects() // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -241,6 +243,13 @@ class ClientEffectsSystem( activeEffects.forEach { it.collectDynamicLights(lights) } } + /** Submits temporary lasers through the screen-owned RF_BEAM renderer. */ + fun renderLasers(modelBatch: ModelBatch, beamRenderer: BeamRenderer) { + laserEffects.submit(currentTimeMsProvider()) { beam -> + beamRenderer.render(modelBatch, beam.origin, beam.destination, beam.paletteIndex, beam.diameter, beam.alpha) + } + } + /** * Renders model/sprite transient effects via `ModelBatch`. * @@ -270,9 +279,10 @@ class ClientEffectsSystem( fun debugParticleRenderStats(): ParticleRenderer.Stats = particleSystem.lastRenderStats() - fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount + shellEffects.activeSustainCount + fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount + shellEffects.activeSustainCount + laserEffects.activeCount override fun dispose() { + laserEffects.clear() shellEffects.clear() heatbeamEffects.clear() beamEffects.clear() @@ -354,7 +364,7 @@ class ClientEffectsSystem( private fun handleTrailMessage(msg: TrailTEMessage) { val now = currentTimeMsProvider() - if (expansionEffects.process(msg, now) || impactEffects.process(msg, now)) return + if (laserEffects.process(msg, now) || expansionEffects.process(msg, now) || impactEffects.process(msg, now)) return val start = toVector3(msg.position) val end = toVector3(msg.destination) when (msg.style) { @@ -408,18 +418,6 @@ class ClientEffectsSystem( emitRailTrailParticles(start, end) playEffectSound("sound/weapons/railgf1a.wav", end) } - - Defines.TE_BFG_LASER -> { - activeEffects += LineBeamEffect( - start = start, - end = end, - color = Color(0.2f, 1f, 0.25f, 1f), - spawnTimeMs = currentTimeMsProvider(), - durationMs = 110, - radius = 0.8f, - alpha = 0.35f, - ) - } } } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt new file mode 100644 index 00000000..b3325450 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt @@ -0,0 +1,35 @@ +package org.demoth.cake.stages.ingame.effects + +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.TrailTEMessage +import kotlin.random.Random + +internal data class LaserBeam(val origin: Vector3f, val destination: Vector3f, val paletteIndex: Int) { + val diameter: Float get() = 4f + val alpha: Float get() = .3f +} + +/** CL_ParseLaser / CL_AddLasers: short-lived palette beams, submitted through the shared RF_BEAM renderer. */ +internal class LaserEffects(private val random: Random = Random.Default, private val capacity: Int = 64) { + init { require(capacity > 0) } + private data class Entry(val beam: LaserBeam, val expiresAt: Long) + private val entries = ArrayList() + val activeCount: Int get() = entries.size + + fun process(message: TrailTEMessage, nowMs: Int): Boolean { + if (message.style != Defines.TE_BFG_LASER) return false + expire(nowMs) + if (entries.size >= capacity || message.position.distance(message.destination) < .001f) return true + entries += Entry(LaserBeam(message.position, message.destination, 0xD0 + random.nextInt(4)), nowMs.toLong() + 100) + return true + } + + fun submit(nowMs: Int, draw: (LaserBeam) -> Unit) { + expire(nowMs) + entries.forEach { draw(it.beam) } + } + + fun clear() = entries.clear() + private fun expire(nowMs: Int) = entries.removeAll { nowMs.toLong() >= it.expiresAt } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt deleted file mode 100644 index d099a754..00000000 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.demoth.cake.stages.ingame.effects - -import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.graphics.GL20 -import com.badlogic.gdx.graphics.VertexAttributes -import com.badlogic.gdx.graphics.g3d.Material -import com.badlogic.gdx.graphics.g3d.Model -import com.badlogic.gdx.graphics.g3d.ModelBatch -import com.badlogic.gdx.graphics.g3d.ModelInstance -import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute -import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute -import com.badlogic.gdx.graphics.g3d.attributes.DepthTestAttribute -import com.badlogic.gdx.graphics.g3d.attributes.IntAttribute -import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder -import com.badlogic.gdx.math.Quaternion -import com.badlogic.gdx.math.Vector3 - -/** - * Short-lived beam segment rendered as procedural cylinder geometry. - * - * Used for TE trail effects where no dedicated model/sprite path exists yet in cake - * (for example rail and BFG laser trails). - * - * Resource behavior: - * each instance owns a small generated [Model] and disposes it on expiry. - */ -@Suppress("DEPRECATION") -class LineBeamEffect( - start: Vector3, - end: Vector3, - color: Color, - private val spawnTimeMs: Int, - private val durationMs: Int, - radius: Float = 0.75f, - alpha: Float = 1f, -) : ClientTransientEffect { - private val model: Model - private val instance: ModelInstance - - init { - val direction = Vector3(end).sub(start) - val length = direction.len().coerceAtLeast(0.001f) - direction.scl(1f / length) - - val materialAttributes = mutableListOf( - ColorAttribute.createDiffuse(color), - ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), - DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), - IntAttribute.createCullFace(GL20.GL_NONE), - ) - if (alpha < 1f) { - materialAttributes += BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha) - } - - val material = Material(*materialAttributes.toTypedArray()) - val modelBuilder = ModelBuilder() - modelBuilder.begin() - modelBuilder.part( - "beam", - GL20.GL_TRIANGLES, - (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), - material, - ).cylinder(2f, 1f, 2f, 6, 0f, 360f) - model = modelBuilder.end() - instance = ModelInstance(model) - - val midpoint = Vector3(start).add(end).scl(0.5f) - val rotation = Quaternion().setFromCross(Vector3.Y, direction) - val scale = Vector3(radius, length, radius) - instance.transform.set(midpoint, rotation, scale) - } - - override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - return nowMs - spawnTimeMs < durationMs - } - - override fun render(modelBatch: ModelBatch) { - modelBatch.render(instance) - } - - override fun dispose() { - model.dispose() - } -} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index a350d3a7..98ea4136 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -30,17 +30,19 @@ Server effect messages / entity-event and trail callbacks WorldPresentationRenderer frame -> effectsSystem.update(): simulate particles, advance/expire effect instances - -> collect replicated trails and lights + -> reset frame lights, collect explosion lights and replicated trails/lights -> draw world and entities, then translucent world -> effectsSystem.renderParticles(): dedicated particle pass -> effectsSystem.render(): transient models/sprites/beams through ModelBatch + -> effectsSystem.renderLasers(): palette lasers through the shared BeamRenderer ``` `ClientTransientEffect.update(nowMs, deltaSeconds)` returns false when an instance expires; the system removes and disposes it in that update loop. Update and render run on the render thread. `AnimatedModelEffect` and `AnimatedSpriteEffect` provide -timed animation; `ExplosionMd2Effect` and `ExplosionSpriteEffect` apply legacy -explosion frame, skin, and alpha rules. Model effects reuse the +timed animation; `ExplosionAnimation` uses libGDX `Animation` for shared explosion +frame, skin, and alpha rules. `ExplosionMd2Effect` and `ExplosionSpriteEffect` +apply that state to their visuals. Model effects reuse the [MD2 pipeline](../../../assets/md2/README.md); sprite effects use `Sp2Renderer`. ## Particle Simulation and Rendering @@ -189,6 +191,24 @@ cloud, teleport bursts and outward widow splash. Ordinary bubbles rise at a steady velocity, and diagnostic trails remain stationary. These are Cake approximations, rather than pixel-perfect replicas of reference emission. +## Explosions and Palette Lasers + +`ExplosionEffect` owns animation state and contributes a frame light whose radius +follows the animation alpha. Lights are collected after the frame-light reset; +expiry removes both the visual and its light. Missing optional visual assets still +retain the animated light. MD2 explosions select frame pairs, skins and translucency +from the shared state; sprite explosions select its current frame. Materials use +standard libGDX alpha blending. `TE_EXPLOSION1_BIG`, `TE_EXPLOSION1_NP` and +`TE_PLAIN_EXPLOSION` do not emit explosion particles. + +`LaserEffects` keeps up to 64 independent BFG laser pulses for 100 ms. Each pulse +chooses a palette color from `0xD0..0xD3`, with diameter 4 and opacity 0.3; its color +stays stable when presentation time pauses. The screen-owned `BeamRenderer` shares +cached libGDX cylinder models with RF_BEAM entities, avoiding per-pulse GPU resource +allocation. Screen teardown clears laser state and disposes the shared renderer. +Synthetic tests cover animation, lights, materials, transforms, expiry and ownership +without game assets or a window. + ## Visual Behaviour and Current Limits Particle families select colours from `q2palette.bin`. Useful compatibility anchors: @@ -200,11 +220,11 @@ Particle families select colours from `q2palette.bin`. Useful compatibility anch Fallback colours keep effects visible when palette data is unavailable. `TE_BFG_EXPLOSION` uses the translucent `sprites/s_bfg2.sp2` explosion path; -`TE_BFG_LASER` remains a procedural `LineBeamEffect` approximation. Parasite/medic -and grapple beams use pooled segment models rebuilt from persistent beam state. Visual fidelity varies by effect -family. Decoded message families dispatch to their presentation handlers; some -existing effects still approximate the reference particle patterns or omit components -which are covered by the remaining fidelity work. +`TE_BFG_LASER` uses the shared palette-beam renderer. Parasite/medic and grapple +beams use pooled segment models rebuilt from persistent beam state. Particle +patterns and procedural geometry remain visual approximations of the reference; +base-game effects, live play, pause and map/session transitions still need visual +verification. Legacy references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, `CL_tent.ParseTEnt`, and `../yquake2/src/client/cl_effects.c`. diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt new file mode 100644 index 00000000..178081db --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt @@ -0,0 +1,57 @@ +package org.demoth.cake.assets + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g3d.Material +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute +import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.math.Vector3f +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +/** Empty libGDX models exercise transforms/material requests/cache ownership without GL or game assets. */ +class BeamRendererTest { + @Test fun `repeated beams share geometry while preserving palette opacity and diameter`() { + val materials = mutableListOf() + var disposed = 0 + val renderer = BeamRenderer({ index -> Color(index / 255f, .5f, .25f, 1f) }, { material -> + materials += material + object : Model() { override fun dispose() { disposed++; super.dispose() } } + }) + try { + val first = renderer.prepare(Vector3f.zero, Vector3f(0f, 10f, 0f), 0xD0, 4f, .3f)!! + assertEquals(Vector3(0f, 5f, 0f), first.transform.getTranslation(Vector3())) + assertEquals(Vector3(2f, 10f, 2f), first.transform.getScale(Vector3())) + repeat(100) { assertSame(first, renderer.prepare(Vector3f.zero, Vector3f(20f, 0f, 0f), 0xD0, 4f, .3f)) } + assertEquals(1, materials.size) + assertEquals(.3f, (materials.single().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + assertEquals(0xD0 / 255f, (materials.single().get(ColorAttribute.Diffuse) as ColorAttribute).color.r) + assertNotSame(first, renderer.prepare(Vector3f.zero, Vector3f.unitX, 0xD1, 4f, .3f)) + renderer.prepare(Vector3f.zero, Vector3f.unitX, 0xD0, 4f, 1f) + assertEquals(3, materials.size) + assertFalse(materials.last().has(BlendingAttribute.Type)) + } finally { renderer.dispose() } + assertEquals(3, disposed) + } + + @Test fun `vertical and short beams produce finite poses and empty or invisible beams allocate no models`() { + var created = 0 + val renderer = BeamRenderer({ Color.WHITE }, { created++; Model() }) + try { + assertNull(renderer.prepare(Vector3f.zero, Vector3f.zero, 1, 4f, .3f)) + assertNull(renderer.prepare(Vector3f.zero, Vector3f.unitX, 1, 0f, .3f)) + assertNull(renderer.prepare(Vector3f.zero, Vector3f.unitX, 1, 4f, 0f)) + assertEquals(0, created) + for (end in listOf(Vector3f(0f, 0f, 10f), Vector3f(0f, -10f, 0f), Vector3f(.01f, 0f, 0f))) { + val instance = renderer.prepare(Vector3f.zero, end, 1, 4f, .3f)!! + assertTrue(instance.transform.`val`.all { it.isFinite() }) + assertTrue(instance.transform.getTranslation(Vector3()).epsilonEquals(Vector3(end.x, end.y, end.z).scl(.5f), .001f)) + } + } finally { renderer.dispose() } + } + + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt new file mode 100644 index 00000000..5b8cd386 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt @@ -0,0 +1,48 @@ +package org.demoth.cake.stages.ingame.effects + +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.TrailTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class LaserEffectsTest { + private fun message(end: Vector3f = Vector3f(100f, 0f, 0f)) = TrailTEMessage(Defines.TE_BFG_LASER, Vector3f.zero, end) + private fun beams(effects: LaserEffects, now: Int): List = buildList { effects.submit(now) { add(it) } } + + @Test fun `BFG lasers keep their endpoints palette diameter and alpha until their hundred millisecond expiry`() { + val effects = LaserEffects(Random(7)) + effects.process(message(), 0) + val first = beams(effects, 0).single() + assertEquals(Vector3f.zero, first.origin) + assertEquals(Vector3f(100f, 0f, 0f), first.destination) + assertTrue(first.paletteIndex in 0xD0..0xD3) + assertEquals(4f, first.diameter) + assertEquals(.3f, first.alpha) + repeat(20) { assertEquals(listOf(first), beams(effects, 50)) } + assertEquals(listOf(first), beams(effects, 99)) + assertTrue(beams(effects, 100).isEmpty()) + } + + @Test fun `overlapping pulses are independent bounded and expired capacity is reusable`() { + val effects = LaserEffects(Random(7), capacity = 2) + effects.process(message(), 0) + effects.process(message(Vector3f.unitZ), 50) + effects.process(message(Vector3f.unitY), 50) + assertEquals(2, effects.activeCount) + assertEquals(2, beams(effects, 50).size) + effects.process(message(Vector3f.unitY), 100) + assertEquals(2, effects.activeCount) + assertEquals(setOf(Vector3f.unitZ, Vector3f.unitY), beams(effects, 100).map { it.destination }.toSet()) + effects.clear() + assertTrue(beams(effects, 100).isEmpty()) + } + + @Test fun `zero length and unrelated trail messages create no laser state`() { + val effects = LaserEffects() + assertTrue(effects.process(message(Vector3f.zero), 0)) + assertFalse(effects.process(message().copy(style = Defines.TE_RAILTRAIL), 0)) + assertEquals(0, effects.activeCount) + } +} From 5f8d2e473bc201f9a5938c36fee58d2016fdc57a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:22:49 +0100 Subject: [PATCH 23/42] Verify all temporary entity presentation lifecycles without graphics --- .../ingame/effects/ClientEffectsSystem.kt | 21 +--- .../ingame/effects/EffectParticleSystem.kt | 9 +- .../stages/ingame/effects/LaserEffects.kt | 6 +- .../effects/TempEntityPresentationTest.kt | 112 ++++++++++++++++++ 4 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index e9a4fe43..533e1205 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -220,6 +220,7 @@ class ClientEffectsSystem( fun update(deltaSeconds: Float, _serverFrame: Int) { val now = currentTimeMsProvider() particleSystem.update(now) + laserEffects.update(now) steamEffects.update(now) shellEffects.update(now) beamModels.beginFrame() @@ -425,12 +426,10 @@ class ClientEffectsSystem( impactEffects.process(msg, currentTimeMsProvider()) if (msg.style == Defines.TE_WELDING_SPARKS) { val position = toVector3(msg.position) - spawnLegacyExplosionEffect( - type = ExplosionType.FLASH, - modelPath = "models/objects/flash/tris.md2", - position = position, - firstFrame = 0, - frameCount = 2, + // CL_ParseTEnt uses RF_BEAM as a no-draw flag here: welding adds a light, not a flash model. + activeEffects += ExplosionEffect( + type = ExplosionType.FLASH, spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, + firstFrame = 0, frameCount = 2, position = position, light = ExplosionLight((100 + Globals.rnd.nextInt(75)).toFloat(), Vector3f(1f, 1f, .3f)), ) } @@ -525,16 +524,6 @@ class ClientEffectsSystem( playEffectSound("sound/weapons/rocklx1a.wav", position) } - Defines.TE_CHAINFIST_SMOKE -> { - spawnAnimatedModelEffect( - modelPath = "models/objects/smoke/tris.md2", - position = position, - firstFrame = 0, - frameCount = 4, - frameDurationMs = 100, - ) - } - Defines.TE_TRACKER_EXPLOSION -> { spawnDynamicLight(position, 150f, -1f, -1f, -1f, lifetimeMs = 100) playEffectSound("sound/weapons/disrupthit.wav", position) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt index 8185ee32..4f7e22a6 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt @@ -32,7 +32,9 @@ class EffectParticleSystem( private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { internal val runtime = GdxParticleRuntime(RenderTuningCvars::particleBudget) - private val renderer = ParticleRenderer(assetManager) + // Simulation and teardown need no GL context until particles are actually drawn. + private val rendererDelegate = lazy { ParticleRenderer(assetManager) } + private val renderer by rendererDelegate private val tempDirection = Vector3() private val tempVelocity = Vector3() private val tempAcceleration = Vector3() @@ -132,11 +134,12 @@ class EffectParticleSystem( fun liveParticleCount(): Int = runtime.liveCount - fun lastRenderStats(): ParticleRenderer.Stats = renderer.lastStats + fun lastRenderStats(): ParticleRenderer.Stats = + if (rendererDelegate.isInitialized()) renderer.lastStats else ParticleRenderer.Stats() override fun dispose() { runtime.dispose() - renderer.dispose() + if (rendererDelegate.isInitialized()) renderer.dispose() } private fun randomDirection(direction: FloatArray?, spread: Float): Vector3 { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt index b3325450..445a54ea 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt @@ -19,17 +19,17 @@ internal class LaserEffects(private val random: Random = Random.Default, private fun process(message: TrailTEMessage, nowMs: Int): Boolean { if (message.style != Defines.TE_BFG_LASER) return false - expire(nowMs) + update(nowMs) if (entries.size >= capacity || message.position.distance(message.destination) < .001f) return true entries += Entry(LaserBeam(message.position, message.destination, 0xD0 + random.nextInt(4)), nowMs.toLong() + 100) return true } fun submit(nowMs: Int, draw: (LaserBeam) -> Unit) { - expire(nowMs) + update(nowMs) entries.forEach { draw(it.beam) } } fun clear() = entries.clear() - private fun expire(nowMs: Int) = entries.removeAll { nowMs.toLong() >= it.expiresAt } + fun update(nowMs: Int) { entries.removeAll { nowMs.toLong() >= it.expiresAt } } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt new file mode 100644 index 00000000..f4fbb91a --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt @@ -0,0 +1,112 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.PerspectiveCamera +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.CakeCvars +import org.demoth.cake.audio.* +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory + +/** Exercises the real dispatcher and frame lifecycle, without render calls, GL or installed assets. */ +class TempEntityPresentationTest { + private class Fixture : AutoCloseable { + var now = 0 + val assets = AssetManager(FileHandleResolver { FileHandle(it) }, false) + val entities = ClientEntityManager() + val lights = DynamicLightSystem() + val effects = ClientEffectsSystem(assets, entities, object : CakeAudioSystem { + override fun beginFrame(listener: ListenerState) = Unit + override fun play(request: SoundPlaybackRequest) = Unit + override fun syncEntityLoopingSounds(requests: Collection) = Unit + override fun endFrame() = Unit + override fun stopAll() = Unit + override fun dispose() = Unit + }, { PerspectiveCamera() }, lights, { now }) + + fun frame(time: Int) { + now = time + effects.update(0f, 0) + lights.beginFrame(now, 0f) + effects.collectDynamicLights() + } + + override fun close() { effects.dispose(); entities.dispose(); assets.dispose() } + } + + private fun messages(): List = buildList { + val start = Vector3f(10f, 20f, 30f) + val end = Vector3f(100f, 40f, 50f) + PointTEMessage.SUBTYPES.forEach { add(PointTEMessage(it, start)) } + PointDirectionTEMessage.SUBTYPES.forEach { add(PointDirectionTEMessage(it, start, 0)) } + TrailTEMessage.SUBTYPES.forEach { add(TrailTEMessage(it, start, end)) } + SplashTEMessage.SUBTYPES.forEach { add(SplashTEMessage(it, 20, start, 0, 1)) } + BeamTEMessage.SUBTYPES.forEach { add(BeamTEMessage(it, 1, start, end)) } + BeamOffsetTEMessage.SUBTYPES.forEach { add(BeamOffsetTEMessage(it, 1, start, end, Vector3f.zero)) } + add(LightningTEMessage(1, 2, start, end)) + add(FlashlightTEMessage(start, 1)) + add(ForceWallTEMessage(start, end, 100)) + add(SteamTEMessage(1, 20, start, 0, 100, 60, 500)) + add(WidowBeamOutTEMessage(1, start)) + } + + @Test fun `fixtures account for every classic supported wire subtype exactly once`() { + assertEquals((0..55).toSet() - setOf(TE_RAILTRAIL2, TE_FLAME), messages().map { it.style }.toSet()) + assertEquals(54, messages().size) + } + + @TestFactory fun `every subtype produces presentation state and expires without further messages or drawing`() = + messages().map { message -> DynamicTest.dynamicTest("temporary entity ${message.style}") { + Fixture().use { fixture -> + fixture.effects.processTempEntityMessage(message) + fixture.frame(0) + val particles = fixture.effects.debugLiveParticleCount() + val effects = fixture.effects.debugActiveEffectCount() + val lights = fixture.lights.visibleLightsForShader().size + assertTrue(particles + effects + lights > 0, "Subtype ${message.style} produced no presentation") + repeat(3) { + fixture.frame(0) + assertEquals(particles, fixture.effects.debugLiveParticleCount(), "Paused emission duplicated particles") + assertEquals(effects, fixture.effects.debugActiveEffectCount()) + assertEquals(lights, fixture.lights.visibleLightsForShader().size) + } + fixture.frame(11_000) + assertEquals(0, fixture.effects.debugActiveEffectCount(), "Effect state outlived its lifetime") + assertEquals(0, fixture.effects.debugLiveParticleCount()) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } } + + @Test fun `teardown clears sustained and beam state and a new session starts empty`() { + Fixture().use { fixture -> + messages().forEach(fixture.effects::processTempEntityMessage) + fixture.frame(0) + assertTrue(fixture.effects.debugActiveEffectCount() > 0) + } + Fixture().use { fixture -> + fixture.frame(0) + assertEquals(0, fixture.effects.debugActiveEffectCount()) + assertEquals(0, fixture.effects.debugLiveParticleCount()) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } + + companion object { + @BeforeAll @JvmStatic fun setup() { + GdxNativesLoader.load() + CakeCvars.registerEntities() + RenderTuningCvars.register() + } + } +} From f2be6424caee766d07994fd11c9b82454ab900ca Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:23:43 +0100 Subject: [PATCH 24/42] Retain effect asset references across overlapping screens --- .../ingame/effects/EffectAssetCatalog.kt | 8 +-- .../ingame/effects/EffectAssetCatalogTest.kt | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt index 66602374..3208903c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt @@ -86,10 +86,10 @@ class EffectAssetCatalog( if (assetManager.fileHandleResolver.tryResolveRaw(path) == null) { return } - if (!assetManager.isLoaded(path, type)) { - assetManager.load(path, type) - assetManager.finishLoadingAsset(path) - } + // Acquire a reference even when another screen/config already loaded the asset. + // finishLoadingAsset returns immediately for cached assets, before processing that acquisition. + assetManager.load(path, type) + assetManager.finishLoading() ownedAssetTypes[path] = type } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt new file mode 100644 index 00000000..49ae0182 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt @@ -0,0 +1,59 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.Array as GdxArray +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy + +class EffectAssetCatalogTest { + @Test fun `overlapping screens and config owners retain independent references to shared assets`() { + val path = "sound/weapons/lashit.wav" + var loaded = 0 + var disposed = 0 + val resolver = FileHandleResolver { if (it == path) FileHandle(it) else null } + val assets = AssetManager(resolver, false) + assets.setLoader(Sound::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): Sound { + loaded++ + return Proxy.newProxyInstance(Sound::class.java.classLoader, arrayOf(Sound::class.java)) { _, method, _ -> + if (method.name == "dispose") disposed++ + null + } as Sound + } + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): GdxArray>? = null + }) + val oldScreen = EffectAssetCatalog(assets) + val newScreen = EffectAssetCatalog(assets) + try { + // Configstrings already own this sound when both screens precache it. + assets.load(path, Sound::class.java) + assets.finishLoading() + oldScreen.precache() + newScreen.precache() + newScreen.precache() + assertEquals(1, loaded) + assertEquals(3, assets.getReferenceCount(path)) + assertSame(oldScreen.getSound(path), newScreen.getSound(path)) + oldScreen.dispose() + oldScreen.dispose() + assertEquals(2, assets.getReferenceCount(path)) + assets.unload(path) + assertNotNull(newScreen.getSound(path)) + assertEquals(0, disposed) + newScreen.dispose() + assertFalse(assets.isLoaded(path)) + assertEquals(1, disposed) + } finally { + oldScreen.dispose() + newScreen.dispose() + assets.dispose() + } + } +} From 307c66a6654d354b1f57a643b7c6037e795dcade Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 21:24:57 +0100 Subject: [PATCH 25/42] Document complete temporary entity coverage and verification limits --- .../cake/stages/ingame/effects/README.md | 88 ++++++++++++++++--- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 98ea4136..0415d887 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -30,7 +30,8 @@ Server effect messages / entity-event and trail callbacks WorldPresentationRenderer frame -> effectsSystem.update(): simulate particles, advance/expire effect instances - -> reset frame lights, collect explosion lights and replicated trails/lights + -> collect replicated trails + -> reset frame lights, collect explosion and replicated-entity lights -> draw world and entities, then translucent world -> effectsSystem.renderParticles(): dedicated particle pass -> effectsSystem.render(): transient models/sprites/beams through ModelBatch @@ -39,8 +40,7 @@ WorldPresentationRenderer frame `ClientTransientEffect.update(nowMs, deltaSeconds)` returns false when an instance expires; the system removes and disposes it in that update loop. Update and render -run on the render thread. `AnimatedModelEffect` and `AnimatedSpriteEffect` provide -timed animation; `ExplosionAnimation` uses libGDX `Animation` for shared explosion +run on the render thread. `ExplosionAnimation` uses libGDX `Animation` for shared explosion frame, skin, and alpha rules. `ExplosionMd2Effect` and `ExplosionSpriteEffect` apply that state to their visuals. Model effects reuse the [MD2 pipeline](../../../assets/md2/README.md); sprite effects use `Sp2Renderer`. @@ -61,8 +61,13 @@ constant-acceleration integration rather than reproducing reference particle mat The runtime is tested directly with synthetic emissions and clocks, without GL, game data or effect assets. libGDX's [3D particle framework](https://libgdx.com/wiki/graphics/3d/3d-particle-effects) -provides the underlying controller/emitter; the choice of stock particle batches -is deferred until the world-pass depth, blending and color requirements are checked. +provides the underlying controller/emitter. The stock `PointSpriteParticleBatch` +and `BillboardParticleBatch` support configurable depth/blending, but their default +shader does not implement Cake's gamma correction, circular cutout or size rule. +Direct replacement would also require splitting the mixed mode/blend channels into +separate batch inputs. The existing drawing adapter retains these engine-specific +requirements; simulation, storage and lifetime remain libGDX-owned. GPU resources +are created on the first particle draw, so dispatch/update/teardown require no GL. `r_particles` supplies the live emission budget: zero or negative values disable emission and drawing, while positive values limit new spawns to the remaining @@ -174,7 +179,8 @@ expiry, pause, resource limits and cleanup. No installed content or window is ne blaster families, including blue-hyperblaster impacts. Spawn positions scatter along the supplied normal, while velocities scatter independently; blaster variants also carry directed velocity and their own palette ranges. Chainfist -smoke rises without downward acceleration. The outer controller retains model +smoke uses particles only and rises without downward acceleration. Welding sparks +add a short yellow light without a flash model. The outer controller retains model flashes and dynamic lights. Splash, laser, welding and tunnel effects preserve their transmitted counts, @@ -205,7 +211,8 @@ standard libGDX alpha blending. `TE_EXPLOSION1_BIG`, `TE_EXPLOSION1_NP` and chooses a palette color from `0xD0..0xD3`, with diameter 4 and opacity 0.3; its color stays stable when presentation time pauses. The screen-owned `BeamRenderer` shares cached libGDX cylinder models with RF_BEAM entities, avoiding per-pulse GPU resource -allocation. Screen teardown clears laser state and disposes the shared renderer. +allocation. Update expires pulses even when no draw occurs. Screen teardown clears +laser state and disposes the shared renderer. Synthetic tests cover animation, lights, materials, transforms, expiry and ownership without game assets or a window. @@ -226,8 +233,64 @@ patterns and procedural geometry remain visual approximations of the reference; base-game effects, live play, pause and map/session transitions still need visual verification. -Legacy references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, -`CL_tent.ParseTEnt`, and `../yquake2/src/client/cl_effects.c`. +Source references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, +`CL_tent.ParseTEnt`, Yamagi's `src/client/cl_tempentities.c` / `cl_effects.c` / +`cl_particles.c`, and Q2PRO's `src/client/tent.c` / `effects.c` in the sibling checkouts. +The comparison targets the classic effects, rather than Q2PRO's extended protocol. +For example, Q2PRO also presents `TE_RAILTRAIL2`; Cake follows Yamagi's supported +set and rejects that type and `TE_FLAME`. Yamagi plays a rocket sound for +`TE_PLAIN_EXPLOSION`, while Q2PRO leaves it silent; Cake retains Yamagi's sound. + +## Protocol-34 Presentation Coverage + +All 54 supported types have a presentation path. Names below omit `TE_`; wire IDs +are included to make the boundary explicit. Optional missing assets, disabled +particles/lights and exhausted budgets can still suppress their respective output. + +| Types (wire IDs) | Presentation owner and components | +| --- | --- | +| GUNSHOT (0), SHOTGUN (4), BULLET_SPARKS (14) | `ImpactEffects` particles; controller smoke/flash; probabilistic ricochet for gunshot/bullet sparks | +| BLOOD (1), GREENBLOOD (26), MOREBLOOD (42) | `ImpactEffects` blood palettes and counts | +| BLASTER (2), BLASTER2 (30), FLECHETTE (55) | `ImpactEffects` directed particles and sound; controller model/skin and animated light | +| RAILTRAIL (3) | Controller helix/core particles and rail sound | +| EXPLOSION1 (5), EXPLOSION2 (6), ROCKET_EXPLOSION (7), GRENADE_EXPLOSION (8), ROCKET_EXPLOSION_WATER (17), GRENADE_EXPLOSION_WATER (18), PLASMA_EXPLOSION (28) | `PointParticles` cloud; controller animated model/light and dry/water sound | +| SPARKS (9) | `ImpactEffects` spark particles | +| SPLASH (10) | `ImpactEffects` transmitted count/palette and spark-splash sound | +| BUBBLETRAIL (11) | Controller rising bubble trail | +| SCREEN_SPARKS (12), SHIELD_SPARKS (13), ELECTRIC_SPARKS (46) | `ImpactEffects` palette particles and impact sound | +| LASER_SPARKS (15), TUNNEL_SPARKS (29) | `ImpactEffects` transmitted count/color, falling/rising particles | +| PARASITE_ATTACK (16), MEDIC_CABLE_ATTACK (19), GRAPPLE_CABLE (24) | `BeamEffects` refreshed cable state and pooled segment models | +| BFG_EXPLOSION (20) | Controller animated sprite and green light | +| BFG_BIGEXPLOSION (21) | Controller green particle cloud | +| BOSSTPORT (22) | `PointParticles` teleport column; controller unattenuated teleport sound | +| BFG_LASER (23) | `LaserEffects` independent pulses; shared `BeamRenderer` geometry | +| WELDING_SPARKS (25) | `ImpactEffects` particles; controller light-only flash | +| BLUEHYPERBLASTER (27) | `ImpactEffects` directed particles; second position field is interpreted as a direction | +| LIGHTNING (33) | `BeamEffects` source/destination-keyed segments and entity-associated Tesla sound | +| DEBUGTRAIL (34) | Controller stationary diagnostic particles | +| PLAIN_EXPLOSION (35), EXPLOSION1_BIG (53), EXPLOSION1_NP (54) | Controller model/light and sound, without explosion particles | +| FLASHLIGHT (36), FORCEWALL (37), BUBBLETRAIL2 (41) | `ExpansionEffects` keyed light, falling wall particles, or rising bubbles plus impact sound | +| HEATBEAM (38), MONSTER_HEATBEAM (39) | `HeatbeamEffects` refreshed attached/world beams; `HeatbeamParticles` rings/shell | +| STEAM (40), HEATBEAM_SPARKS (43), HEATBEAM_STEAM (44) | `SteamEffects` instant/sustained bursts; controller sound for heatbeam impacts | +| CHAINFIST_SMOKE (45) | `ImpactEffects` rising smoke particles | +| TRACKER_EXPLOSION (47) | `PointParticles` black cloud; controller negative light and disruption sound | +| TELEPORT_EFFECT (48), DBALL_GOAL (49), WIDOWSPLASH (52) | Controller teleport burst or outward widow shell particles | +| WIDOWBEAMOUT (50), NUKEBLAST (51) | `ShellEffects` bounded, timed expanding particle pulses | + +`TempEntityPresentationTest` exercises every type through the real dispatcher with +synthetic messages and no optional assets. It checks visible-effect state, paused +updates, expiry without drawing, and a fresh session after teardown. Family tests +cover the detailed particle/beam geometry, timing, sounds, budgets and material +requests; `TemporaryEntityCodecTest` independently checks wire layouts and bounded +rejections. These tests do not validate GPU output or audible playback. + +User-run visual checks still needed after the final changes: + +- Base-game gunshot/shotgun/blaster/rail impacts, dry/water explosions and BFG effects. +- Expansion beams, welding and chainfist smoke in live play; the three Rogue demos + were already reported playable and visually good before the final audit fixes. +- Pause/resume during sustained effects, followed by a map change and disconnect/ + reconnect, checking for frozen leftovers, missing assets or duplicated effects. ## Assets and Lifetime @@ -236,9 +299,10 @@ listed MD2 models, SP2 sprites, and sounds that configstrings may not supply. Files absent from raw resolver lookup are skipped; catalog lookups return null for unloaded assets. Present files still pass through the normal loaders. -The catalog records paths visited during precache, reuses already loaded assets, -and unloads its tracked paths during disposal. This tracking is separate from -configuration asset ownership. +The catalog acquires one AssetManager reference per present asset, including +already loaded assets, and releases that reference during disposal. Repeated +precache/dispose calls do not acquire/release twice. This keeps config assets and +overlapping screens independent during map transitions. `ClientEffectsSystem.dispose()` disposes remaining transient instances, particle meshes/shaders, its sprite renderer, and the asset catalog. Shared model/sprite From cf4b181eed1a533587e885f17053a257ea6279e1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 22:57:58 +0100 Subject: [PATCH 26/42] Add bounded protocol 34 demo record writer --- .../jake2/qcommon/network/demo/DemoWriter.kt | 52 +++++++ .../qcommon/network/demo/DemoWriterTest.kt | 142 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoWriter.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoWriterTest.kt diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoWriter.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoWriter.kt new file mode 100644 index 00000000..853ac028 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoWriter.kt @@ -0,0 +1,52 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.transport.PacketBytes +import java.io.Closeable +import java.io.IOException +import java.io.OutputStream + +/** + * Owns a protocol-34 .dm2 output stream (Yamagi CL_WriteDemoMessage / CL_Stop_f). + * Records contain server message bodies, without channel sequence/acknowledgement headers. + * The caller supplies validated bodies; this layer only handles framing and stream lifetime. + */ +class DemoWriter(private val output: OutputStream) : Closeable { + private var closed = false + private val header = ByteArray(4) + + /** Writes one bounded record, including empty records. An I/O failure closes the incomplete stream. */ + fun write(body: PacketBytes) { + check(!closed) { "Demo writer is closed" } + require(body.size <= Defines.MAX_MSGLEN) { "Demo record exceeds ${Defines.MAX_MSGLEN} bytes" } + val bytes = body.toByteArray() + try { + writeLength(bytes.size) + output.write(bytes) + } catch (failure: IOException) { + // A partial record cannot be retried or safely followed by a normal end marker. + closed = true + try { + output.close() + } catch (closeFailure: IOException) { + if (closeFailure !== failure) failure.addSuppressed(closeFailure) + } + throw failure + } + } + + private fun writeLength(length: Int) { + for (index in header.indices) header[index] = (length ushr (index * 8)).toByte() + output.write(header) + } + + /** Finishes a healthy recording with -1, flushes and closes once, even if finalization fails. */ + override fun close() { + if (closed) return + closed = true + output.use { + writeLength(-1) + it.flush() + } + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoWriterTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoWriterTest.kt new file mode 100644 index 00000000..614427eb --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoWriterTest.kt @@ -0,0 +1,142 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.PrintMessage +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.transport.PacketBytes +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream + +class DemoWriterTest { + @Test fun `record bytes match an independent protocol 34 print fixture without channel headers`() { + // Hand-authored .dm2 framing from Yamagi CL_WriteDemoMessage and CL_Stop_f; + // svc_print is opcode 10 followed by level and a zero-terminated string. + val body = byteArrayOf(10, 2, 'h'.code.toByte(), 'i'.code.toByte(), 0) + val output = ByteArrayOutputStream() + val owned = PacketBytes.copyOf(body) + body.fill(0) + DemoWriter(output).use { it.write(owned) } + assertArrayEquals(byteArrayOf(5, 0, 0, 0, 10, 2, 'h'.code.toByte(), 'i'.code.toByte(), 0, -1, -1, -1, -1), + output.toByteArray()) + DemoReader(ByteArrayInputStream(output.toByteArray())).use { reader -> + val record = reader.read() as DemoReader.Record.Body + val decoded = MessageBodyCodec.decodeServerBody(record.bytes) as MessageDecodeResult.Decoded + assertEquals("hi", (decoded.messages.single() as PrintMessage).text) + assertEquals(DemoReader.Record.End, reader.read()) + } + } + + @Test fun `empty and full capacity records round trip in order including lengths larger than one byte`() { + val bodies = listOf(0, 1, 255, 256, Defines.MAX_MSGLEN).map { size -> + PacketBytes.copyOf(ByteArray(size) { (it * 31).toByte() }) + } + val output = ByteArrayOutputStream() + DemoWriter(output).use { writer -> bodies.forEach(writer::write) } + DemoReader(ByteArrayInputStream(output.toByteArray())).use { reader -> + bodies.forEach { assertEquals(it, (reader.read() as DemoReader.Record.Body).bytes) } + assertEquals(DemoReader.Record.End, reader.read()) + } + } + + @Test fun `oversized record is rejected before any output and a valid recording can still follow`() { + val output = ByteArrayOutputStream() + DemoWriter(output).use { writer -> + assertThrows(IllegalArgumentException::class.java) { + writer.write(PacketBytes.copyOf(ByteArray(Defines.MAX_MSGLEN + 1))) + } + assertEquals(0, output.size()) + writer.write(PacketBytes.copyOf(byteArrayOf(6))) + } + assertArrayEquals(byteArrayOf(1, 0, 0, 0, 6, -1, -1, -1, -1), output.toByteArray()) + } + + @Test fun `closing an empty recording writes a single terminator flushes and closes once`() { + val output = ProbeOutput() + val writer = DemoWriter(output) + writer.close() + writer.close() + assertArrayEquals(byteArrayOf(-1, -1, -1, -1), output.bytes.toByteArray()) + assertEquals(1, output.flushes) + assertEquals(1, output.closes) + assertThrows(IllegalStateException::class.java) { writer.write(PacketBytes.copyOf(byteArrayOf(6))) } + } + + @Test fun `failure at every record byte closes the output without appending a terminator or retrying`() { + for (limit in 0 until 7) { + val output = ProbeOutput(limit = limit) + val writer = DemoWriter(output) + val failure = assertThrows(IOException::class.java) { writer.write(PacketBytes.copyOf(byteArrayOf(6, 6, 6))) } + assertSame(output.writeFailure, failure) + assertEquals(limit, output.bytes.size()) + assertEquals(1, output.closes) + writer.close() + assertEquals(limit, output.bytes.size()) + assertEquals(1, output.closes) + assertThrows(IllegalStateException::class.java) { writer.write(PacketBytes.copyOf(byteArrayOf(6))) } + DemoReader(ByteArrayInputStream(output.bytes.toByteArray())).use { reader -> + assertThrows(IOException::class.java) { reader.read() } + } + } + } + + @Test fun `partial terminator or failed flush still closes once and preserves the primary error`() { + for (limit in 0..3) { + val output = ProbeOutput(limit = limit, failClose = true) + val writer = DemoWriter(output) + val failure = assertThrows(IOException::class.java) { writer.close() } + assertSame(output.writeFailure, failure) + assertEquals(listOf(output.closeFailure), failure.suppressed.toList()) + assertEquals(1, output.closes) + writer.close() + assertEquals(1, output.closes) + } + val output = ProbeOutput(failFlush = true, failClose = true) + val writer = DemoWriter(output) + val failure = assertThrows(IOException::class.java) { writer.close() } + assertSame(output.flushFailure, failure) + assertEquals(listOf(output.closeFailure), failure.suppressed.toList()) + assertEquals(1, output.closes) + writer.close() + assertEquals(1, output.closes) + } + + @Test fun `record failure preserves a secondary close error and final close failure is not retried`() { + val output = ProbeOutput(limit = 5, failClose = true) + val writer = DemoWriter(output) + val failure = assertThrows(IOException::class.java) { writer.write(PacketBytes.copyOf(byteArrayOf(6, 6))) } + assertSame(output.writeFailure, failure) + assertEquals(listOf(output.closeFailure), failure.suppressed.toList()) + writer.close() + assertEquals(1, output.closes) + + val closeOnly = ProbeOutput(failClose = true) + val closingWriter = DemoWriter(closeOnly) + assertSame(closeOnly.closeFailure, assertThrows(IOException::class.java) { closingWriter.close() }) + closingWriter.close() + assertEquals(1, closeOnly.closes) + } + + private class ProbeOutput( + val limit: Int = Int.MAX_VALUE, + val failFlush: Boolean = false, + val failClose: Boolean = false, + ) : OutputStream() { + val bytes = ByteArrayOutputStream() + var closes = 0 + var flushes = 0 + val writeFailure = IOException("synthetic write failure") + val flushFailure = IOException("synthetic flush failure") + val closeFailure = IOException("synthetic close failure") + override fun write(value: Int) { + if (bytes.size() >= limit) throw writeFailure + bytes.write(value) + } + override fun flush() { flushes++; if (failFlush) throw flushFailure } + override fun close() { closes++; if (failClose) throw closeFailure } + } +} From e4aa10a9172ba41c8ab394b9f06855e69efd8dab Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 23:15:50 +0100 Subject: [PATCH 27/42] Add owned demo bootstrap and full snapshot recording gate --- .../org/demoth/cake/demo/DemoBootstrap.kt | 42 +++++ .../org/demoth/cake/demo/DemoRecording.kt | 86 +++++++++ .../org/demoth/cake/demo/DemoRecordingTest.kt | 168 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt new file mode 100644 index 00000000..5c055de7 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt @@ -0,0 +1,42 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t + +/** Owned startup records for a protocol-34 client recording (Yamagi CL_Record_f). */ +class DemoBootstrap( + serverData: ServerDataMessage, + configStrings: Iterable, + baselines: Iterable, +) { + private val records: List + + init { + require(serverData.protocol == Defines.PROTOCOL_VERSION) { "Unsupported recording protocol ${serverData.protocol}" } + require(serverData.playerNumber in 0 until Defines.MAX_CLIENTS) { "Recording requires a player view" } + val messages = buildList { + add(serverData.copy(demo = true)) + addAll(configStrings.filter { it.config.isNotEmpty() }.sortedBy { it.index }) + addAll(baselines.sortedBy { it.entityState.index }) + add(StuffTextMessage("precache\n")) + } + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + records = buildList { + for (message in messages) { + require(message.size <= buffer.maxsize) { "Demo bootstrap message exceeds record capacity" } + if (buffer.cursize + message.size > buffer.maxsize) { + add(PacketBytes.copyOf(buffer.data, buffer.cursize)) + buffer.cursize = 0 + } + message.writeTo(buffer) + } + if (buffer.cursize > 0) add(PacketBytes.copyOf(buffer.data, buffer.cursize)) + } + } + + /** All records were encoded at construction; later source collection changes cannot affect them. */ + fun writeTo(writer: DemoWriter) { records.forEach(writer::write) } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt new file mode 100644 index 00000000..6e4a725e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt @@ -0,0 +1,86 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.transport.PacketBytes +import java.io.Closeable +import java.io.IOException + +/** + * Owns recording readiness and output, independently of sockets, a local host, or presentation. + * [bootstrap] snapshots the latest client startup state when a full frame is ready, including + * config/baseline changes received while waiting. The live client supplies reconstruction validity. + */ +class DemoRecording(private val writer: DemoWriter, private val bootstrap: () -> DemoBootstrap) : Closeable { + private var waiting = true + private var closed = false + private val recordedFrames = IntArray(Defines.UPDATE_BACKUP) { -1 } + + /** The command sender must request a full snapshot without invalidating the displayed frame. */ + val needsFullSnapshot: Boolean get() = !closed && waiting + + /** + * Called after the client processes an accepted packet. [snapshotAccepted] must indicate whether + * its snapshot was reconstructed successfully; structural decoding alone cannot establish that. + * Whole recorded bodies are preserved, including messages before/after their snapshot. + */ + fun capture(body: PacketBytes, snapshotAccepted: Boolean) { + check(!closed) { "Demo recording is closed" } + try { + val messages = when (val decoded = MessageBodyCodec.decodeServerBody(body)) { + is MessageDecodeResult.Decoded -> decoded.messages + is MessageDecodeResult.Rejected -> throw IOException("Invalid recording payload: ${decoded.detail}") + } + if (messages.any { it is ServerDataMessage }) { + // Map/session reset: capture fresh startup state with that map's first full snapshot. + waitForFullSnapshot() + return + } + val frames = messages.filterIsInstance() + val frame = frames.singleOrNull() + val player = messages.indexOfFirst { it is PlayerInfoMessage } + val entities = messages.indexOfFirst { it is PacketEntitiesMessage } + if (frames.isNotEmpty() || player >= 0 || entities >= 0) { + val complete = frame != null && messages.indexOf(frame) < player && player < entities && + messages.count { it is PlayerInfoMessage } == 1 && messages.count { it is PacketEntitiesMessage } == 1 + if (!complete || !snapshotAccepted) { + waitForFullSnapshot() + return + } + // A live-valid delta may still depend on a frame received before recording began. + if (frame != null && frame.lastFrame > 0 && + recordedFrames[frame.lastFrame and (recordedFrames.size - 1)] != frame.lastFrame) { + waitForFullSnapshot() + return + } + } + if (waiting) { + if (frame == null || frame.lastFrame > 0) return + bootstrap().writeTo(writer) + waiting = false + } + writer.write(body) + if (frame != null) recordedFrames[frame.frameNumber and (recordedFrames.size - 1)] = frame.frameNumber + } catch (failure: Exception) { + try { close() } catch (closeFailure: Exception) { + if (closeFailure !== failure) failure.addSuppressed(closeFailure) + } + throw failure + } + } + + private fun waitForFullSnapshot() { + waiting = true + recordedFrames.fill(-1) + } + + override fun close() { + if (!closed) { + closed = true + writer.close() + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt new file mode 100644 index 00000000..36a3bd0a --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt @@ -0,0 +1,168 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException + +class DemoRecordingTest { + private fun server(map: String = "first") = ServerDataMessage.forClient(7, false, "baseq2", 0, map) + private fun bootstrap(map: String = "first") = DemoBootstrap(server(map), listOf(ConfigStringMessage(0, map)), emptyList()) + private fun body(vararg messages: ServerMessage): PacketBytes { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return PacketBytes.copyOf(buffer.data, buffer.cursize) + } + private fun frame(number: Int, last: Int = -1) = body( + PrintMessage(2, "before $number"), FrameHeaderMessage(number, last, 0, 0, byteArrayOf()), + PlayerInfoMessage(fov = 90), PacketEntitiesMessage(emptyList()), PrintMessage(2, "after $number")) + private fun records(bytes: ByteArray): List = buildList { + DemoReader(ByteArrayInputStream(bytes)).use { reader -> + while (true) when (val record = reader.read()) { + is DemoReader.Record.Body -> add(record.bytes) + DemoReader.Record.End -> break + } + } + } + private fun decode(body: PacketBytes) = (MessageBodyCodec.decodeServerBody(body) as MessageDecodeResult.Decoded).messages + + @Test fun `bootstrap owns startup state sets demo flag and packs whole messages into bounded records`() { + val config = (0..80).map { ConfigStringMessage(it, "value$it".repeat(6)) }.toMutableList() + config += ConfigStringMessage(81, "") + val engine = entity_state_t(1).apply { modelindex = 3; origin[0] = 32f } + val baseline = SpawnBaselineMessage(engine) + val baselines = mutableListOf(baseline) + val startup = DemoBootstrap(server(), config, baselines) + config.clear(); baselines.clear(); engine.clear() + val output = ByteArrayOutputStream() + DemoWriter(output).use(startup::writeTo) + val bodies = records(output.toByteArray()) + assertTrue(bodies.size > 1) + assertTrue(bodies.all { it.size in 1..Defines.MAX_MSGLEN }) + val decoded = bodies.flatMap(::decode) + assertEquals(server().copy(demo = true), decoded.first()) + assertEquals(81, decoded.filterIsInstance().size) + assertEquals((0..80).toList(), decoded.filterIsInstance().map { it.index }) + assertEquals(baseline, decoded.filterIsInstance().single()) + assertEquals(StuffTextMessage("precache\n"), decoded.last()) + } + + @Test fun `bootstrap rejects unsupported startup and oversized messages before writing output`() { + assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(protocol = 26), emptyList(), emptyList()) } + assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(playerNumber = -1), emptyList(), emptyList()) } + assertThrows(IllegalArgumentException::class.java) { + DemoBootstrap(server(), listOf(ConfigStringMessage(0, "x".repeat(Defines.MAX_MSGLEN))), emptyList()) + } + } + + @Test fun `recording waits for an accepted complete full snapshot then preserves raw bodies through playback`() { + val output = ByteArrayOutputStream() + var currentMap = "old" + var bootstraps = 0 + val recording = DemoRecording(DemoWriter(output)) { bootstraps++; bootstrap(currentMap) } + val full = frame(100) + val delta = frame(101, 100) + val final = body(PrintMessage(2, "final message")) + recording.use { + assertTrue(it.needsFullSnapshot) + it.capture(body(PrintMessage(2, "not started")), false) + it.capture(frame(99, 98), true) + it.capture(full, false) + it.capture(body(FrameHeaderMessage(100, -1, 0, 0, byteArrayOf())), true) + assertEquals(0, output.size()) + assertEquals(0, bootstraps) + currentMap = "latest" + it.capture(full, true) + assertFalse(it.needsFullSnapshot) + it.capture(delta, true) + it.capture(final, false) + } + assertFalse(recording.needsFullSnapshot) + val saved = records(output.toByteArray()) + assertEquals(listOf(full, delta, final), saved.drop(1)) + assertEquals("latest", (decode(saved.first()).first() as ServerDataMessage).levelString) + val presented = mutableListOf() + DemoPlayback(DemoReader(ByteArrayInputStream(output.toByteArray()))).use { playback -> + val dispatch: (List) -> Boolean = { messages -> + presented += messages.filterIsInstance().map { it.frameNumber }; true + } + assertTrue(playback.update(0f, dispatch)) + assertTrue(playback.update(.1f, dispatch)) + assertFalse(playback.update(.1f, dispatch)) + } + assertEquals(listOf(100, 101), presented) + assertEquals(1, bootstraps) + } + + @Test fun `a map reset or rejected snapshot waits for a fresh full frame and takes fresh startup state`() { + val output = ByteArrayOutputStream() + var map = "first" + DemoRecording(DemoWriter(output)) { bootstrap(map) }.use { recording -> + recording.capture(frame(100), true) + recording.capture(frame(101, 100), false) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(102, 101), true) + recording.capture(frame(103), true) + map = "second" + recording.capture(body(server(map), ConfigStringMessage(0, map)), false) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(1, 10), true) + recording.capture(frame(2, 0), true) + } + val decoded = records(output.toByteArray()).flatMap(::decode) + assertEquals(listOf(100, 103, 2), decoded.filterIsInstance().map { it.frameNumber }) + assertEquals(listOf("first", "first", "second"), decoded.filterIsInstance().map { it.levelString }) + } + + @Test fun `live valid deltas must reference frames saved in this recording and its current map`() { + val output = ByteArrayOutputStream() + DemoRecording(DemoWriter(output)) { bootstrap() }.use { recording -> + recording.capture(frame(100), true) + recording.capture(frame(101, 100), true) + recording.capture(frame(102, 100), true) + assertFalse(recording.needsFullSnapshot) + recording.capture(frame(103, 99), true) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(104, 102), true) + recording.capture(frame(105), true) + recording.capture(body(server("next")), false) + recording.capture(frame(106, 105), true) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(107), true) + } + val savedFrames = records(output.toByteArray()).flatMap(::decode).filterIsInstance() + assertEquals(listOf(100, 101, 102, 105, 107), savedFrames.map { it.frameNumber }) + } + + @Test fun `malformed bodies write no valid prefix and close the recording`() { + val output = ByteArrayOutputStream() + val recording = DemoRecording(DemoWriter(output)) { bootstrap() } + recording.capture(frame(1), true) + val before = output.size() + val malformed = PacketBytes.copyOf(body(PrintMessage(2, "prefix")).toByteArray() + byteArrayOf(254.toByte())) + assertThrows(IOException::class.java) { recording.capture(malformed, false) } + assertEquals(before + 4, output.size()) + assertThrows(IllegalStateException::class.java) { recording.capture(frame(2), true) } + recording.close() + assertEquals(2, records(output.toByteArray()).size) + } + + @Test fun `stopping before a full frame closes without requesting or fabricating any snapshot`() { + val output = ByteArrayOutputStream() + val recording = DemoRecording(DemoWriter(output)) { error("No full frame ready") } + recording.close() + recording.close() + assertFalse(recording.needsFullSnapshot) + assertTrue(records(output.toByteArray()).isEmpty()) + } +} From e1d91f77d8087a8a4de28e05aed2d6c6a7ddcf90 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 23:29:19 +0100 Subject: [PATCH 28/42] Expose owned recording startup state and explicit full snapshot requests --- .../demoth/cake/config/GameConfiguration.kt | 6 +++ .../org/demoth/cake/input/InputManager.kt | 4 +- .../cake/stages/ingame/ClientEntityManager.kt | 5 +++ .../demoth/cake/stages/ingame/Game3dScreen.kt | 15 ++++++- .../cake/demo/DemoStartupSnapshotTest.kt | 40 +++++++++++++++++++ .../input/InputManagerDeltaCompressionTest.kt | 14 +++++++ 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt index 32f26ccd..d18901c3 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt @@ -8,6 +8,7 @@ import com.badlogic.gdx.utils.Disposable import com.badlogic.gdx.utils.GdxRuntimeException import jake2.qcommon.Com import jake2.qcommon.Defines.* +import jake2.qcommon.network.messages.server.ConfigStringMessage import jake2.qcommon.exec.Cmd import org.demoth.cake.assets.bsp.BspMapAsset import org.demoth.cake.assets.MissingResourceException @@ -240,6 +241,11 @@ class GameConfiguration( return configStrings.getOrNull(CS_ITEMS + itemIndex)?.value } + /** Owned protocol values for demo startup; resource handles are excluded. */ + fun snapshotConfigStrings(): List = configStrings.mapIndexedNotNull { index, config -> + config?.value?.takeIf { it.isNotEmpty() }?.let { ConfigStringMessage(index, it) } + } + fun getConfigValue(configIndex: Int): String? { return configStrings.getOrNull(configIndex)?.value } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt b/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt index ae26f02c..4e3beda9 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt @@ -108,7 +108,7 @@ class InputManager( } // called at fixed client command cadence - fun buildMoveMessage(outgoingSequence: Int, currentFrame: ClientFrame, commandMsec: Int): MoveMessage { + fun buildMoveMessage(outgoingSequence: Int, currentFrame: ClientFrame, commandMsec: Int, requestFullSnapshot: Boolean = false): MoveMessage { val nowNanos = nowNanosProvider() // assemble the inputs and commands, then transmit them @@ -157,7 +157,7 @@ class InputManager( // deliver the message return MoveMessage.fromInput( - !currentFrame.valid, + requestFullSnapshot || !currentFrame.valid, currentFrame.serverframe, oldestCmd, oldCmd, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt index 4ed0423a..d27f6eb6 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt @@ -99,6 +99,11 @@ class ClientEntityManager : Disposable { return clientEntities[entityIndex].current.angles.copyOf() } + /** Copy received baselines for demo startup without sharing mutable entity storage. */ + fun snapshotBaselines(): List = clientEntities.mapNotNull { entity -> + entity.baseline.takeIf { it.index > 0 }?.let(::SpawnBaselineMessage) + } + fun processBaselineMessage(msg: SpawnBaselineMessage) { msg.entityState.copyTo(clientEntities[msg.entityState.index].baseline) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index a6483773..e87a5f8a 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -1,5 +1,7 @@ package org.demoth.cake.stages.ingame +import org.demoth.cake.demo.DemoBootstrap + import org.demoth.cake.assets.bsp.BspEntityLightSampler import org.demoth.cake.assets.bsp.BspInlineSurfaceMaterialController import org.demoth.cake.assets.bsp.BspInlineTextureAnimationController @@ -852,8 +854,8 @@ class Game3dScreen( inputManager.updateLocalInput(deltaTime, entityManager.currentFrame) } - fun buildMoveMessage(outgoingSequence: Int, commandMsec: Int): MoveMessage { - return inputManager.buildMoveMessage(outgoingSequence, entityManager.currentFrame, commandMsec) + fun buildMoveMessage(outgoingSequence: Int, commandMsec: Int, requestFullSnapshot: Boolean = false): MoveMessage { + return inputManager.buildMoveMessage(outgoingSequence, entityManager.currentFrame, commandMsec, requestFullSnapshot) } @Deprecated("Use updateLocalInput + buildMoveMessage") @@ -1043,10 +1045,19 @@ class Game3dScreen( // region SERVER MESSAGE PARSING + private var recordingServerData: ServerDataMessage? = null + + /** Snapshot current startup data when the recorder receives its first full frame. */ + fun demoBootstrap(): DemoBootstrap = DemoBootstrap( + checkNotNull(recordingServerData) { "No server data available for recording" }, + gameConfig.snapshotConfigStrings(), entityManager.snapshotBaselines(), + ) + /** * CL_ParseServerData */ override fun processServerDataMessage(msg: ServerDataMessage) { + recordingServerData = msg levelString = msg.levelString // player slot used by prediction/entity visibility/HUD highlighting. gameConfig.playerConfiguration.playerIndex = msg.playerNumber diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt new file mode 100644 index 00000000..898d42a8 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt @@ -0,0 +1,40 @@ +package org.demoth.cake.demo + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.messages.server.SpawnBaselineMessage +import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DemoStartupSnapshotTest { + @Test fun `startup capture copies live configstrings and received baselines without loading resources`() { + CakeCvars.registerEntities() + val assets = AssetManager(FileHandleResolver(::FileHandle), false) + val config = GameConfiguration(assets) + val entities = ClientEntityManager() + try { + config.applyConfigString(0, "first") + config.applyConfigString(1, "") + val baseline = entity_state_t(17).apply { modelindex = 3; origin[0] = 32f } + entities.processBaselineMessage(SpawnBaselineMessage(baseline)) + val configs = config.snapshotConfigStrings() + val baselines = entities.snapshotBaselines() + config.applyConfigString(0, "second") + baseline.modelindex = 4 + baseline.origin[0] = 64f + entities.processBaselineMessage(SpawnBaselineMessage(baseline)) + assertEquals("first", configs.single().config) + assertEquals(17, baselines.single().entityState.index) + assertEquals(3, baselines.single().entityState.modelIndex) + assertEquals(32f, baselines.single().entityState.origin.x) + assertEquals("second", config.snapshotConfigStrings().single().config) + assertEquals(4, entities.snapshotBaselines().single().entityState.modelIndex) + assertEquals(0, assets.loadedAssets) + } finally { entities.dispose(); assets.dispose() } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt index 8e65c965..2c2d58e2 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt @@ -37,6 +37,20 @@ class InputManagerDeltaCompressionTest { CakeCvars.registerInput() } + @Test + fun recordingCanRequestFullSnapshotWithoutInvalidatingPresentationOrDiscardingInput() { + val input = InputManager(nowNanosProvider = { 1_000_000L }) + val current = frame(serverframe = 77, valid = true) + input.updateLocalInput(0f, current) + val full = roundTrip(input.buildMoveMessage(5, current, 16, requestFullSnapshot = true), 5) + assertTrue(full.lastReceivedFrame <= 0) + assertTrue(current.valid) + assertEquals(77, current.serverframe) + assertEquals(16, full.newCmd.msec) + val delta = roundTrip(input.buildMoveMessage(6, current, 16), 6) + assertEquals(77, delta.lastReceivedFrame) + } + @Test fun invalidCurrentFrameRequestsFullFrameResync() { val input = InputManager(nowNanosProvider = { 1_000_000L }) From 699ca3f6d444e1e11ccc13b9f03e409f347e8de0 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 23:31:51 +0100 Subject: [PATCH 29/42] Store demo recordings in profile writable directories --- .../kotlin/org/demoth/cake/demo/DemoFiles.kt | 50 +++++++++++++++++ .../org/demoth/cake/demo/DemoFilesTest.kt | 53 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt new file mode 100644 index 00000000..55502007 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt @@ -0,0 +1,50 @@ +package org.demoth.cake.demo + +import jake2.qcommon.vfs.VfsOpenOptions +import jake2.qcommon.vfs.VfsWriteOptions +import jake2.qcommon.vfs.WritableFileSystem +import java.io.FilterInputStream +import java.io.FilterOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** Profile-local recordings take precedence over installed/archived demos with the same logical name. */ +internal class DemoFiles( + private val writable: () -> WritableFileSystem, + private val openContent: (String) -> InputStream?, +) { + data class Output(val path: String, val stream: OutputStream) + + fun create(name: String): Output { + val path = demoLogicalPath(name) ?: throw IOException("Invalid demo path: $name") + val files = writable() + // Recording names are explicit; don't silently overwrite a previous take. + val opened = files.openWrite(path, VfsWriteOptions(false, false)) + val handle = opened.value ?: throw IOException(opened.error ?: "Could not create $path") + try { + val output = object : FilterOutputStream(handle.outputStream()) { + override fun write(bytes: ByteArray, offset: Int, length: Int) = out.write(bytes, offset, length) + override fun close() = handle.close() + } + return Output(files.resolveWritePath(path), output) + } catch (failure: Exception) { + try { handle.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + + fun open(name: String): InputStream { + val path = demoLogicalPath(name) ?: throw IOException("Invalid demo path: $name") + val local = writable().openReadReal(path, VfsOpenOptions.DEFAULT).value + if (local != null) { + try { + return object : FilterInputStream(local.inputStream()) { override fun close() = local.close() } + } catch (failure: Exception) { + try { local.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + return openContent(path) ?: throw IOException("Demo not found in active profile: $path") + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt new file mode 100644 index 00000000..3b3df191 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt @@ -0,0 +1,53 @@ +package org.demoth.cake.demo + +import jake2.qcommon.vfs.DefaultWritableFileSystem +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +class DemoFilesTest { + @TempDir lateinit var root: Path + + @Test fun `recordings are profile local replayable by name and never overwrite earlier takes`() { + var profile = "first" + val files = DemoFiles({ DefaultWritableFileSystem(root.resolve(profile)) }, { null }) + files.create("series/take").let { output -> + assertEquals(root.resolve("first/demos/series/take.dm2").toString(), output.path) + output.stream.use { it.write(byteArrayOf(1, 2, 3)) } + } + files.open("series/take").use { assertArrayEquals(byteArrayOf(1, 2, 3), it.readBytes()) } + assertThrows(IOException::class.java) { files.create("series/take") } + files.open("series/take").use { assertArrayEquals(byteArrayOf(1, 2, 3), it.readBytes()) } + profile = "second" + assertThrows(IOException::class.java) { files.open("series/take") } + files.create("series/take").stream.use { it.write(4) } + files.open("series/take").use { assertArrayEquals(byteArrayOf(4), it.readBytes()) } + } + + @Test fun `local demo wins over installed content and missing local demo falls back to content`() { + val fallbackPaths = mutableListOf() + val files = DemoFiles({ DefaultWritableFileSystem(root) }, { path -> + fallbackPaths += path + if (path == "demos/installed.dm2") ByteArrayInputStream(byteArrayOf(7)) else null + }) + files.open("installed").use { assertEquals(7, it.read()) } + assertEquals(listOf("demos/installed.dm2"), fallbackPaths) + files.create("installed").stream.use { it.write(8) } + files.open("installed").use { assertEquals(8, it.read()) } + assertEquals(1, fallbackPaths.size) + assertThrows(IOException::class.java) { files.open("missing") } + } + + @Test fun `invalid paths do not reach the filesystem or content lookup`() { + val files = DemoFiles({ error("Unexpected write filesystem access") }, { error("Unexpected content lookup") }) + for (path in listOf("", "../escape", "/absolute", "demos/../../escape", "C:\\demo")) { + assertThrows(IOException::class.java) { files.create(path) } + assertThrows(IOException::class.java) { files.open(path) } + } + assertEquals(0L, Files.list(root).use { it.count() }) + } +} From 165d1d814e4b5490a0b32efeb3ae3ebb59d057aa Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 23:31:51 +0100 Subject: [PATCH 30/42] Wire console recording into live client packet and session lifecycles --- cake/README.md | 19 ++- .../src/main/kotlin/org/demoth/cake/Cake.kt | 78 +++++++++- .../org/demoth/cake/demo/DemoRecording.kt | 10 +- .../demoth/cake/demo/LiveDemoRecordingTest.kt | 137 ++++++++++++++++++ 4 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt diff --git a/cake/README.md b/cake/README.md index ab20fe8d..9c2233bd 100644 --- a/cake/README.md +++ b/cake/README.md @@ -159,13 +159,26 @@ the singleplayer respawn action opens Load Game automatically. remain visible in the menu so an invalid save or failed restore can be retried. Deathmatch does not create autosaves. Coop save/load remains console-only. -## Demo playback +## Demo recording and playback -Both `cake-client` and `cake-fullgame` play protocol-34 client recordings (`.dm2`) +Both `cake-client` and `cake-fullgame` can record the connected client's view: + +- `record example` — start recording an active live game, remote or locally hosted. +- `stop` — finish and close the recording. Disconnect/shutdown also finishes it. +- `demomap example` — replay the saved recording. + +Recordings are saved under `~/.cake//demos/`; nested names are supported. +Existing recordings are not overwritten: choose a new name for another take. +Recording waits for an accepted full snapshot, requesting one without invalidating +the displayed frame. Map transitions remain in the same recording. An I/O failure +stops recording and reports an error while gameplay continues. A recording stopped +before its first full snapshot has no playable frames. + +Both launchers play protocol-34 client recordings (`.dm2`) without running a game server. Select a profile containing the recording's maps, models, and sounds, then open the client console: -- `demomap example` — play `demos/example.dm2` from the active profile's game data. +- `demomap example` — play the active profile's saved `demos/example.dm2`, falling back to game data. - `demomap demos/folder/example.dm2` — play a nested demo, including archive entries. - `demopause` — toggle pause; opening the menu or suspending the application also pauses playback. - `demostop` or `disconnect` — stop and return to the console. diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index 837e827b..cefbfe99 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -17,6 +17,10 @@ import com.badlogic.gdx.utils.viewport.ScreenViewport import java.io.IOException import jake2.qcommon.network.demo.DemoReader import org.demoth.cake.demo.DemoPlayback +import org.demoth.cake.demo.DemoRecording +import org.demoth.cake.demo.DemoFiles +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.protocol.PacketEnvelope import org.demoth.cake.demo.demoLogicalPath import jake2.qcommon.Com import jake2.qcommon.Defines.* @@ -133,6 +137,8 @@ class Cake( private var awaitingNewGame = false // Connection and download protocol state. + private var demoRecording: DemoRecording? = null + private val demoFiles = DemoFiles(profilePersistence::writable, fileResolver::openContent) private var demoPlayback: DemoPlayback? = null private var demoPaused = false private var networkState = DISCONNECTED @@ -323,6 +329,13 @@ class Cake( } + Cmd.AddCommand("record", "Record a protocol-34 client demo: record ") { args -> + if (args.size != 2) Com.Printf("usage: record \n") else startRecording(args[1]) + } + Cmd.AddCommand("stop", "Finish the current client demo recording") { + if (demoRecording == null) Com.Printf("Not recording a demo\n") else stopRecording() + } + Cmd.AddCommand("demomap", "Play a protocol-34 demo: demomap ") { args -> if (args.size != 2) { Com.Printf("usage: demomap \n") @@ -516,6 +529,51 @@ class Cake( return result } + private fun startRecording(name: String) { + if (demoRecording != null) { + Com.Warn("Already recording a demo\n") + return + } + if (demoPlayback != null || networkState != ACTIVE || game3dScreen == null) { + Com.Warn("Recording requires an active live game\n") + return + } + try { + val output = demoFiles.create(name) + demoRecording = DemoRecording(DemoWriter(output.stream)) { + checkNotNull(game3dScreen) { "No active game for recording startup" }.demoBootstrap() + } + Com.Printf("Recording to ${output.path}; waiting for a full snapshot\n") + } catch (error: Exception) { + Com.Warn("Could not start recording: ${error.message}\n") + } + } + + private fun stopRecording() { + val recording = demoRecording ?: return + demoRecording = null + try { + recording.close() + Com.Printf("Stopped demo recording\n") + } catch (error: Exception) { + Com.Warn("Could not finish recording: ${error.message}\n") + } + } + + private fun captureDemoBody(recording: DemoRecording, packet: PacketEnvelope.Sequenced, snapshotAccepted: Boolean) { + if (demoRecording !== recording) return + try { + recording.capture(packet, snapshotAccepted) + } catch (error: Exception) { + // Recording failures retire the file owner, while the live game keeps running. + demoRecording = null + try { recording.close() } catch (closing: Exception) { + if (closing !== error) error.addSuppressed(closing) + } + Com.Warn("Demo recording failed: ${error.message}\n") + } + } + private fun startDemo(name: String) { val path = demoLogicalPath(name) if (path == null) { @@ -523,8 +581,8 @@ class Cake( return } val input = try { - fileResolver.openContent(path) ?: throw IOException("Demo not found in active profile: $path") - } catch (error: IOException) { + demoFiles.open(name) + } catch (error: Exception) { Com.Warn("${error.message}\n") return } @@ -587,6 +645,7 @@ class Cake( /** Reset client state without closing the hosted server or discarding its reconnect traffic. */ private fun resetClientConnection() { + stopRecording() val playback = demoPlayback val wasDemo = playback != null demoPlayback = null @@ -884,7 +943,8 @@ class Cake( ACTIVE -> { queueUserInfoUpdateIfNeeded() - game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec())?.let { + game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec(), + requestFullSnapshot = demoRecording?.needsFullSnapshot == true)?.let { transmitAndRecord(listOf(it)) } } @@ -1171,7 +1231,11 @@ class Cake( if (message !is FrameHeaderMessage && message !is PlayerInfoMessage && message !is PacketEntitiesMessage) Com.Printf("Network: Received server msg: $message\n") } - parseServerMessage(body.messages) + val recording = demoRecording + val envelope = networkPacket.sequencedEnvelope() + parseServerMessage(body.messages) { snapshotAccepted -> + if (recording != null) captureDemoBody(recording, envelope, snapshotAccepted) + } } is MessageDecodeResult.Rejected -> { Com.Warn("Bad server payload at ${body.offset}: ${body.detail}\n") @@ -1187,7 +1251,8 @@ class Cake( /** * CL_ParseServerMessage */ - private fun parseServerMessage(messages: Collection) { + private fun parseServerMessage(messages: Collection, onParsed: (Boolean) -> Unit = {}) { + var snapshotAccepted = false // Important ordering quirk: accept() updates netchan ack from packet header before parsing // payload messages. We push those values into prediction first, mirroring old `CL_ReadPackets` // + `CL_pred.CheckPredictionError/PredictMovement` data flow. @@ -1200,6 +1265,7 @@ class Cake( when (msg) { is DisconnectMessage -> { Com.Printf("Server disconnected\n") + onParsed(snapshotAccepted) dropToConsole() return } @@ -1292,6 +1358,7 @@ class Cake( is PacketEntitiesMessage -> { val valid = game3dScreen?.processPacketEntitiesMessage(msg) == true + snapshotAccepted = valid if (demoPlayback != null && !valid) throw IOException("Demo snapshot depends on an unavailable frame") if (valid) { if (networkState != ACTIVE) { @@ -1325,6 +1392,7 @@ class Cake( } } + onParsed(snapshotAccepted) } /** diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt index 6e4a725e..a5b0286e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt @@ -5,6 +5,7 @@ import jake2.qcommon.network.demo.DemoWriter import jake2.qcommon.network.messages.server.* import jake2.qcommon.network.protocol.MessageBodyCodec import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.protocol.PacketEnvelope import jake2.qcommon.network.transport.PacketBytes import java.io.Closeable import java.io.IOException @@ -22,6 +23,13 @@ class DemoRecording(private val writer: DemoWriter, private val bootstrap: () -> /** The command sender must request a full snapshot without invalidating the displayed frame. */ val needsFullSnapshot: Boolean get() = !closed && waiting + /** Live server packets retain their exact body; channel headers are never part of .dm2 records. */ + fun capture(packet: PacketEnvelope.Sequenced, snapshotAccepted: Boolean) { + require(packet.qport == null) { "Recording expects a server packet" } + capture(PacketBytes.copyOfRange(packet.bytes.toByteArray(), packet.headerSize, + packet.bytes.size - packet.headerSize), snapshotAccepted) + } + /** * Called after the client processes an accepted packet. [snapshotAccepted] must indicate whether * its snapshot was reconstructed successfully; structural decoding alone cannot establish that. @@ -51,7 +59,7 @@ class DemoRecording(private val writer: DemoWriter, private val bootstrap: () -> return } // A live-valid delta may still depend on a frame received before recording began. - if (frame != null && frame.lastFrame > 0 && + if (frame.lastFrame > 0 && recordedFrames[frame.lastFrame and (recordedFrames.size - 1)] != frame.lastFrame) { waitForFullSnapshot() return diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt new file mode 100644 index 00000000..9512c5c2 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt @@ -0,0 +1,137 @@ +package org.demoth.cake.demo + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.input.InputManager +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** Real body codecs, startup snapshots and client frame reconstruction; no graphics or game resources. */ +class LiveDemoRecordingTest { + private class Client : AutoCloseable { + val assets = AssetManager(FileHandleResolver(::FileHandle), false) + var config = GameConfiguration(assets) + var entities = ClientEntityManager() + var server: ServerDataMessage? = null + val positions = mutableListOf() + val prints = mutableListOf() + var disconnected = false + + fun apply(messages: List): Boolean { + var accepted = false + for (message in messages) when (message) { + is ServerDataMessage -> { + entities.dispose() + entities = ClientEntityManager() + config = GameConfiguration(assets) + server = message + } + is ConfigStringMessage -> config.applyConfigString(message.index, message.config) + is SpawnBaselineMessage -> entities.processBaselineMessage(message) + is FrameHeaderMessage -> entities.processServerFrameHeader(message) + is PlayerInfoMessage -> entities.processPlayerInfoMessage(message) + is PacketEntitiesMessage -> { + accepted = entities.processPacketEntitiesMessage(message) + if (accepted) positions += entities.getEntityOrigin(1)!!.x + } + is PrintMessage -> prints += message.text + DisconnectMessage -> disconnected = true + else -> Unit + } + return accepted + } + fun bootstrap() = DemoBootstrap(checkNotNull(server), config.snapshotConfigStrings(), entities.snapshotBaselines()) + override fun close() { entities.dispose(); assets.dispose() } + } + + private fun body(messages: List): PacketBytes { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return PacketBytes.copyOf(buffer.data, buffer.cursize) + } + private fun state(x: Float) = EntityStateSnapshot(1, origin = Vector3f(x, 0f, 0f), modelIndex = 3) + private fun frame(number: Int, last: Int, from: EntityStateSnapshot, to: EntityStateSnapshot) = listOf( + FrameHeaderMessage(number, last, 0, 0, byteArrayOf()), PlayerInfoMessage(fov = 90), + PacketEntitiesMessage(listOf(EntityUpdate.Change(checkNotNull(EntityDeltaCodec.plan(from, to, true, last <= 0)))))) + private fun startup(map: String, baseline: EntityStateSnapshot): List = listOf( + ServerDataMessage.forClient(1, false, "baseq2", 0, map), ConfigStringMessage(0, map), SpawnBaselineMessage(baseline)) + + @Test fun `live packet capture requests a full frame and replays reconstructed entity deltas across maps`() { + CakeCvars.registerEntities() + CakeCvars.registerInput() + val output = ByteArrayOutputStream() + val capturedBodies = mutableListOf() + Client().use { live -> + var sequence = 1 + fun receive(messages: List, recording: DemoRecording? = null) { + val raw = body(messages) + val wire = PacketCodec.encodeSequenced(sequence++, 0, null, raw) + val packet = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope as PacketEnvelope.Sequenced + val decoded = (MessageBodyCodec.decodeServer(packet) as MessageDecodeResult.Decoded).messages + val accepted = live.apply(decoded) + recording?.capture(packet, accepted) + if (recording != null && messages.any { it is FrameHeaderMessage } && !recording.needsFullSnapshot) + capturedBodies += raw + } + val firstBaseline = state(32f) + receive(startup("first", firstBaseline)) + receive(frame(50, -1, firstBaseline, state(64f))) + DemoRecording(DemoWriter(output), live::bootstrap).use { recording -> + val input = InputManager(nowNanosProvider = { 1_000_000L }) + input.updateLocalInput(0f, live.entities.currentFrame) + assertTrue(input.buildMoveMessage(1, live.entities.currentFrame, 16, recording.needsFullSnapshot).lastReceivedFrame <= 0) + assertTrue(live.entities.currentFrame.valid) + receive(frame(51, 50, state(64f), state(72f)), recording) + assertTrue(recording.needsFullSnapshot) + receive(frame(52, -1, firstBaseline, state(80f)), recording) + assertEquals(52, input.buildMoveMessage(2, live.entities.currentFrame, 16, recording.needsFullSnapshot).lastReceivedFrame) + receive(frame(53, 52, state(80f), state(96f)), recording) + val nextBaseline = state(128f) + receive(startup("second", nextBaseline), recording) + assertTrue(recording.needsFullSnapshot) + receive(frame(1, -1, nextBaseline, state(144f)), recording) + receive(frame(2, 1, state(144f), state(160f)), recording) + // The application captures the final accepted payload before closing on disconnect. + receive(listOf(PrintMessage(2, "goodbye"), DisconnectMessage), recording) + } + } + val records = buildList { + DemoReader(ByteArrayInputStream(output.toByteArray())).use { reader -> + while (true) when (val next = reader.read()) { + is DemoReader.Record.Body -> add(next.bytes) + DemoReader.Record.End -> break + } + } + } + val savedFrameBodies = records.filter { raw -> + (MessageBodyCodec.decodeServerBody(raw) as MessageDecodeResult.Decoded).messages.any { it is FrameHeaderMessage } + } + assertEquals(capturedBodies, savedFrameBodies, "Channel headers must be stripped without changing payload bytes") + Client().use { replay -> + DemoPlayback(DemoReader(ByteArrayInputStream(output.toByteArray()))).use { playback -> + val dispatch: (List) -> Boolean = { replay.apply(it); true } + var running = playback.update(0f, dispatch) + repeat(10) { if (running) running = playback.update(.1f, dispatch) } + assertFalse(running) + } + assertEquals(listOf(80f, 96f, 144f, 160f), replay.positions) + assertEquals(listOf("goodbye"), replay.prints) + assertTrue(replay.disconnected) + assertEquals("second", replay.config.getConfigValue(0)) + } + } +} From de5dec27114710f711ea99e6bb3d05ce5c53450c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:02:59 +0100 Subject: [PATCH 31/42] Reserve channel overhead in demo startup records for Yamagi playback --- .../org/demoth/cake/demo/DemoBootstrap.kt | 4 ++- .../org/demoth/cake/demo/DemoRecordingTest.kt | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt index 5c055de7..cc736236 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt @@ -23,7 +23,9 @@ class DemoBootstrap( addAll(baselines.sortedBy { it.entityState.index }) add(StuffTextMessage("precache\n")) } - val buffer = sizebuf_t(Defines.MAX_MSGLEN) + // Yamagi SV_SendClientMessages forwards demo records through Netchan_Transmit. + // Reserve channel header/control space even though .dm2 records contain no channel header. + val buffer = sizebuf_t(Defines.MAX_MSGLEN - 16) records = buildList { for (message in messages) { require(message.size <= buffer.maxsize) { "Demo bootstrap message exceeds record capacity" } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt index 36a3bd0a..a6f7030f 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt @@ -7,6 +7,12 @@ import jake2.qcommon.network.demo.DemoWriter import jake2.qcommon.network.messages.server.* import jake2.qcommon.network.protocol.MessageBodyCodec import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.protocol.ChannelRole +import jake2.qcommon.network.protocol.ReliableChannel +import jake2.qcommon.network.protocol.PrepareSendResult +import jake2.qcommon.network.protocol.PacketCodec +import jake2.qcommon.network.protocol.PacketDecodeResult +import jake2.qcommon.network.protocol.PacketDirection import jake2.qcommon.network.transport.PacketBytes import jake2.qcommon.sizebuf_t import org.junit.jupiter.api.Assertions.* @@ -48,7 +54,7 @@ class DemoRecordingTest { DemoWriter(output).use(startup::writeTo) val bodies = records(output.toByteArray()) assertTrue(bodies.size > 1) - assertTrue(bodies.all { it.size in 1..Defines.MAX_MSGLEN }) + assertTrue(bodies.all { it.size in 1..(Defines.MAX_MSGLEN - 16) }) val decoded = bodies.flatMap(::decode) assertEquals(server().copy(demo = true), decoded.first()) assertEquals(81, decoded.filterIsInstance().size) @@ -65,6 +71,30 @@ class DemoRecordingTest { } } + @Test fun `startup records survive forwarding through a classic server channel without dropped payloads`() { + // Yamagi SV_SendClientMessages forwards each .dm2 body through Netchan_Transmit as unreliable data. + // A record can fit the file format limit yet be dropped when its channel header is added. + val startup = DemoBootstrap(server(), listOf( + ConfigStringMessage(Defines.CS_STATUSBAR, "x".repeat(Defines.MAX_MSGLEN - 20)), + ), listOf(SpawnBaselineMessage(EntityStateSnapshot(index = 1, modelIndex = 3)))) + val output = ByteArrayOutputStream() + DemoWriter(output).use(startup::writeTo) + val expected = records(output.toByteArray()).flatMap(::decode) + val forwarded = records(output.toByteArray()).flatMap { record -> + val channel = ReliableChannel(ChannelRole.SERVER, 0) + // Also leave room for a small reliable control payload alongside the server header. + channel.enqueueReliable(PacketBytes.copyOf(ByteArray(8) { ServerMessageType.svc_nop.type.toByte() })) + val send = channel.prepareSend(record) as PrepareSendResult.Prepared + assertFalse(send.unreliableDropped, "Startup record of ${record.size} bytes was dropped") + assertTrue(send.bytes.size <= Defines.MAX_MSGLEN) + val packet = (PacketCodec.decode(send.bytes, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + (MessageBodyCodec.decodeServer(packet) as MessageDecodeResult.Decoded).messages + } + assertEquals(expected, forwarded) + assertTrue(forwarded.first() is ServerDataMessage) + assertEquals(StuffTextMessage("precache\n"), forwarded.last()) + } + @Test fun `recording waits for an accepted complete full snapshot then preserves raw bodies through playback`() { val output = ByteArrayOutputStream() var currentMap = "old" From d1a9fd63a14193a6b16bbf760d4ef243e2359681 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:19:03 +0100 Subject: [PATCH 32/42] Enable classic protocol 32 demo playback --- cake/README.md | 4 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 2 +- .../org/demoth/cake/demo/DemoPlayback.kt | 5 +- .../org/demoth/cake/demo/DemoPlaybackTest.kt | 55 ++++++++++++++++++- .../org/demoth/cake/demo/DemoRecordingTest.kt | 4 +- .../jake2/qcommon/network/demo/DemoReader.kt | 2 +- 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/cake/README.md b/cake/README.md index 9c2233bd..cd520980 100644 --- a/cake/README.md +++ b/cake/README.md @@ -161,7 +161,7 @@ Deathmatch does not create autosaves. Coop save/load remains console-only. ## Demo recording and playback -Both `cake-client` and `cake-fullgame` can record the connected client's view: +Both `cake-client` and `cake-fullgame` can record the connected client's view using protocol 34: - `record example` — start recording an active live game, remote or locally hosted. - `stop` — finish and close the recording. Disconnect/shutdown also finishes it. @@ -174,7 +174,7 @@ the displayed frame. Map transitions remain in the same recording. An I/O failur stops recording and reports an error while gameplay continues. A recording stopped before its first full snapshot has no playable frames. -Both launchers play protocol-34 client recordings (`.dm2`) +Both launchers play classic protocol-32 and protocol-34 client recordings (`.dm2`) without running a game server. Select a profile containing the recording's maps, models, and sounds, then open the client console: diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index cefbfe99..02d5240c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -336,7 +336,7 @@ class Cake( if (demoRecording == null) Com.Printf("Not recording a demo\n") else stopRecording() } - Cmd.AddCommand("demomap", "Play a protocol-34 demo: demomap ") { args -> + Cmd.AddCommand("demomap", "Play a protocol-32/34 demo: demomap ") { args -> if (args.size != 2) { Com.Printf("usage: demomap \n") } else { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt index 56698554..20b54a81 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt @@ -74,8 +74,9 @@ class DemoPlayback(private val reader: DemoReader) : Closeable { } if (messages.any { it is ServerDataMessage }) { for (data in messages.filterIsInstance()) { - if (data.protocol != Defines.PROTOCOL_VERSION) - throw IOException("Unsupported demo protocol ${data.protocol}; expected ${Defines.PROTOCOL_VERSION}") + // Classic protocol 32 uses the same frame/body layouts as 34, including suppressCount. + if (data.protocol != 32 && data.protocol != Defines.PROTOCOL_VERSION) + throw IOException("Unsupported demo protocol ${data.protocol}; expected 32 or ${Defines.PROTOCOL_VERSION}") if (data.playerNumber == -1) throw IOException("Cinematic demos are not supported") if (data.playerNumber !in 0 until Defines.MAX_CLIENTS) throw IOException("Invalid demo player slot ${data.playerNumber}") } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt index 7f43a524..855feabf 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt @@ -49,8 +49,61 @@ class DemoPlaybackTest { } } + @Test fun `independent protocol 32 startup and frame layouts retain suppression visibility and timing`() { + // Hand-authored classic layouts from Yamagi CL_ParseServerData/CL_ParseFrame and + // Q2PRO CL_ParseFrame: only protocol 26 omits suppressCount; 32 follows the 34 layout. + val startup = byteArrayOf(12) + int(32) + int(1) + byteArrayOf(1) + + "xatrix\u0000".toByteArray(Charsets.ISO_8859_1) + byteArrayOf(0, 0) + + "legacy32\u0000".toByteArray(Charsets.ISO_8859_1) + fun snapshot(number: Int, last: Int): ByteArray = byteArrayOf(20) + int(number) + int(last) + + byteArrayOf(7, 1, 3, // suppressCount, areaBits length, areaBits + 17, 0, 0, 0, 0, 0, 0, // playerinfo: no changed fields or stats + 18, 0, 0) // packetentities terminator + fun framed(bytes: ByteArray) = int(bytes.size) + bytes + // Version and first snapshot intentionally share a record. + playback(framed(startup + snapshot(100, -1)) + framed(snapshot(101, 100)) + int(-1)).use { demo -> + val messages = mutableListOf() + val dispatch: (List) -> Boolean = { messages += it; true } + assertTrue(demo.update(0f, dispatch)) + val data = messages.filterIsInstance().single() + assertEquals(32, data.protocol) + assertEquals("xatrix", data.gameName) + assertEquals("legacy32", data.levelString) + assertEquals(9900, demo.presentationTimeMillis) + repeat(3) { assertTrue(demo.update(0f, dispatch)) } + assertEquals(1, messages.filterIsInstance().size) + assertTrue(demo.update(.1f, dispatch)) + val frames = messages.filterIsInstance() + assertEquals(listOf(100, 101), frames.map { it.frameNumber }) + assertEquals(listOf(-1, 100), frames.map { it.lastFrame }) + frames.forEach { + assertEquals(7, it.suppressCount) + assertArrayEquals(byteArrayOf(3), it.areaBits.toByteArray()) + } + assertFalse(demo.update(.1f, dispatch)) + } + } + + @Test fun `map changes can switch between supported classic demo versions`() { + playback(bootstrap(32) + frame(1000) + bootstrap(34) + frame(5) + int(-1)).use { demo -> + val protocols = mutableListOf() + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { messages -> + protocols += messages.filterIsInstance().map { it.protocol } + frames += messages.filterIsInstance().map { it.frameNumber } + true + } + assertTrue(demo.update(0f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertFalse(demo.update(.1f, dispatch)) + assertEquals(listOf(32, 34), protocols) + assertEquals(listOf(1000, 5), frames) + } + } + @Test fun `unsupported protocol and malformed body dispatch no prefix`() { - for (bytes in listOf(bootstrap(26), record(ServerDataMessage(34, 1, true, "baseq2", 0, "test")) + int(2) + byteArrayOf(6, 7))) { + for (bytes in listOf(bootstrap(26), bootstrap(33), bootstrap(35), + record(ServerDataMessage(34, 1, true, "baseq2", 0, "test")) + int(2) + byteArrayOf(6, 7))) { playback(bytes).use { demo -> assertThrows(IOException::class.java) { demo.update(0f) { true } } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt index a6f7030f..691aab85 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt @@ -64,7 +64,9 @@ class DemoRecordingTest { } @Test fun `bootstrap rejects unsupported startup and oversized messages before writing output`() { - assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(protocol = 26), emptyList(), emptyList()) } + for (protocol in listOf(26, 32)) { + assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(protocol = protocol), emptyList(), emptyList()) } + } assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(playerNumber = -1), emptyList(), emptyList()) } assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server(), listOf(ConfigStringMessage(0, "x".repeat(Defines.MAX_MSGLEN))), emptyList()) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt index 5c5f89ad..e8b52e5f 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoReader.kt @@ -6,7 +6,7 @@ import java.io.Closeable import java.io.IOException import java.io.InputStream -/** Owns a protocol-34 .dm2 stream: little-endian lengths followed by server message bodies. */ +/** Owns a classic .dm2 stream: little-endian lengths followed by server message bodies. */ class DemoReader(private val input: InputStream) : Closeable { sealed interface Record { data class Body(val bytes: PacketBytes) : Record From 0474f1427d546d58bc4f10f412f91b31ac177125 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:29:27 +0100 Subject: [PATCH 33/42] Add stream owned classic demo protocol decoding --- .../network/demo/DemoMessageDecoder.kt | 38 +++++++++ .../messages/server/ServerMessageCodec.kt | 22 +++-- .../network/protocol/MessageBodyCodec.kt | 6 +- .../network/demo/DemoMessageDecoderTest.kt | 82 +++++++++++++++++++ 4 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoMessageDecoder.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoMessageDecoderTest.kt diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoMessageDecoder.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoMessageDecoder.kt new file mode 100644 index 00000000..36a0f660 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/demo/DemoMessageDecoder.kt @@ -0,0 +1,38 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.ServerDataMessage +import jake2.qcommon.network.messages.server.ServerMessage +import jake2.qcommon.network.messages.server.ServerMessageCodec +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.protocol.MessageReadException +import jake2.qcommon.network.protocol.MessageRejection +import jake2.qcommon.network.transport.PacketBytes + +/** + * Per-playback classic protocol context. Server data selects the layout immediately, including + * for later messages in the same record. Rejected records commit neither messages nor version. + * Live networking and recording continue to use the protocol-34 body codec. + */ +class DemoMessageDecoder { + var protocol: Int = Defines.PROTOCOL_VERSION + private set + + fun decode(bytes: PacketBytes): MessageDecodeResult { + var nextProtocol = protocol + val result = MessageBodyCodec.decodeServerBody(bytes) { reader -> + val start = reader.readcount + val message = ServerMessageCodec.readDemoMessage(reader, nextProtocol) + if (message is ServerDataMessage) { + if (message.protocol != 26 && message.protocol != 32 && message.protocol != Defines.PROTOCOL_VERSION) + throw MessageReadException(MessageRejection.INVALID_VALUE, start, + "Unsupported demo protocol ${message.protocol}; expected 26, 32 or ${Defines.PROTOCOL_VERSION}") + nextProtocol = message.protocol + } + message + } + if (result is MessageDecodeResult.Decoded) protocol = nextProtocol + return result + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt index 92d81359..00ee4fa0 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt @@ -89,10 +89,15 @@ object ServerMessageCodec { /** Construct complete values from bounded reads; no-op and end markers keep their existing outcomes. */ @JvmStatic fun read(buffer: sizebuf_t): ServerMessage? { - if (buffer !is MessageReader) { - val reader = MessageReader.fromBuffer(buffer) - return try { read(reader) } finally { buffer.readcount = reader.readcount } - } + val reader = if (buffer is MessageReader) buffer else MessageReader.fromBuffer(buffer) + return try { readMessage(reader, frameHasSuppressCount = true) } finally { buffer.readcount = reader.readcount } + } + + /** Demo-only layout selection; live packet decoding always uses protocol 34. */ + internal fun readDemoMessage(buffer: MessageReader, protocol: Int): ServerMessage? = + readMessage(buffer, frameHasSuppressCount = protocol != 26) + + private fun readMessage(buffer: MessageReader, frameHasSuppressCount: Boolean): ServerMessage? { if (buffer.readcount == buffer.cursize) return EndOfServerPacketMessage val opcode = buffer.readUnsignedByte() return when (ServerMessageType.fromInt(opcode)) { @@ -116,7 +121,7 @@ object ServerMessageCodec { ServerMessageType.svc_spawnbaseline -> SpawnBaselineMessage(EntityDeltaCodec.read(buffer)) ServerMessageType.svc_inventory -> InventoryMessage(IntArray(MAX_ITEMS) { buffer.readSignedShort() }) ServerMessageType.svc_download -> readDownload(buffer) - ServerMessageType.svc_frame -> readFrameHeader(buffer) + ServerMessageType.svc_frame -> readFrameHeader(buffer, frameHasSuppressCount) ServerMessageType.svc_configstring -> { val index = buffer.readUnsignedShort() if (index >= MAX_CONFIGSTRINGS) @@ -164,12 +169,11 @@ object ServerMessageCodec { } } - private fun readFrameHeader(buffer: MessageReader): FrameHeaderMessage { + private fun readFrameHeader(buffer: MessageReader, hasSuppressCount: Boolean): FrameHeaderMessage { val frameNumber = buffer.readInt() val lastFrame = buffer.readInt() - // The legacy parser mentioned omitting suppressCount for protocol-26 demos. - // Keep the existing protocol-34 layout: this byte is always present. - val suppressCount = buffer.readUnsignedByte() + // CL_ParseFrame (Yamagi/Q2PRO): protocol 26 omits this byte; classic 32/34 include it. + val suppressCount = if (hasSuppressCount) buffer.readUnsignedByte() else 0 val length = buffer.readUnsignedByte() if (length > MAX_MAP_AREAS / 8) throw MessageReadException(MessageRejection.INVALID_LENGTH, buffer.readcount - 1, "Invalid area bits length $length") diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt index efeef6fd..b6a4b289 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt @@ -15,7 +15,11 @@ object MessageBodyCodec { /** Decode a complete server body, including demo records which have no channel header. */ @JvmStatic fun decodeServerBody(bytes: PacketBytes): MessageDecodeResult = - decodeReader(MessageReader(bytes, 0), ServerMessageCodec::read) { it is EndOfServerPacketMessage } + decodeServerBody(bytes, ServerMessageCodec::read) + + /** A stream-owned demo decoder may select its layout between messages within the same record. */ + internal fun decodeServerBody(bytes: PacketBytes, parse: (MessageReader) -> ServerMessage?): MessageDecodeResult = + decodeReader(MessageReader(bytes, 0), parse) { it is EndOfServerPacketMessage } @JvmStatic fun decodeClient(envelope: PacketEnvelope, sequence: Int): MessageDecodeResult = decode(envelope, PacketDirection.FROM_CLIENT, { ClientMessageCodec.read(it, sequence) }) { it is EndOfClientPacketMessage } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoMessageDecoderTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoMessageDecoderTest.kt new file mode 100644 index 00000000..e7c325e2 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/demo/DemoMessageDecoderTest.kt @@ -0,0 +1,82 @@ +package jake2.qcommon.network.demo + +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.PacketBytes +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoMessageDecoderTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + // Independent classic wire fixtures from Yamagi CL_ParseServerData/CL_ParseFrame and Q2PRO CL_ParseFrame. + private fun startup(protocol: Int) = byteArrayOf(12) + int(protocol) + int(1) + byteArrayOf(1) + + "baseq2\u0000".toByteArray() + byteArrayOf(0, 0) + "test\u0000".toByteArray() + private fun frame(protocol: Int, number: Int = 10) = byteArrayOf(20) + int(number) + int(-1) + + (if (protocol == 26) byteArrayOf() else byteArrayOf(9)) + byteArrayOf(1, 3) + private fun decode(decoder: DemoMessageDecoder, bytes: ByteArray) = decoder.decode(PacketBytes.copyOf(bytes)) + private fun messages(decoder: DemoMessageDecoder, bytes: ByteArray) = + (decode(decoder, bytes) as MessageDecodeResult.Decoded).messages + + @Test fun `protocol 26 server data selects the shorter frame header inside the same record and persists`() { + val decoder = DemoMessageDecoder() + val first = messages(decoder, startup(26) + frame(26)) + assertEquals(26, decoder.protocol) + val header = first.last() as FrameHeaderMessage + assertEquals(10, header.frameNumber) + assertEquals(-1, header.lastFrame) + assertEquals(0, header.suppressCount) + assertArrayEquals(byteArrayOf(3), header.areaBits.toByteArray()) + assertEquals(11, (messages(decoder, frame(26, 11)).single() as FrameHeaderMessage).frameNumber) + } + + @Test fun `map server data can switch between all supported layouts without leaking across decoder instances`() { + val decoder = DemoMessageDecoder() + for (protocol in listOf(34, 26, 32, 26, 34)) { + val header = messages(decoder, startup(protocol) + frame(protocol)).last() as FrameHeaderMessage + assertEquals(protocol, decoder.protocol) + assertEquals(if (protocol == 26) 0 else 9, header.suppressCount) + assertArrayEquals(byteArrayOf(3), header.areaBits.toByteArray()) + } + val independent = DemoMessageDecoder() + messages(decoder, startup(26)) + assertEquals(34, independent.protocol) + assertEquals(9, (messages(independent, frame(34)).single() as FrameHeaderMessage).suppressCount) + } + + @Test fun `every truncated header prefix rejects without committing a protocol change`() { + val header = frame(26) + for (length in 1 until header.size) { + val decoder = DemoMessageDecoder() + messages(decoder, startup(32)) + val result = decode(decoder, startup(26) + header.copyOf(length)) as MessageDecodeResult.Rejected + assertEquals(MessageRejection.TRUNCATED, result.reason, "Prefix $length") + assertEquals(32, decoder.protocol) + assertEquals(9, (messages(decoder, frame(32)).single() as FrameHeaderMessage).suppressCount) + } + } + + @Test fun `unsupported versions malformed suffixes and oversized areas reject whole records atomically`() { + val decoder = DemoMessageDecoder() + for (bytes in listOf(startup(25), startup(33), startup(35), + startup(26) + byteArrayOf(254.toByte()), + startup(26) + byteArrayOf(20) + int(10) + int(-1) + byteArrayOf(33))) { + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(decoder, bytes)) + assertEquals(34, decoder.protocol) + } + } + + @Test fun `protocol 26 demo context never changes ordinary body or live packet decoding`() { + val decoder = DemoMessageDecoder() + messages(decoder, startup(26) + frame(26)) + val classic34 = PacketBytes.copyOf(frame(34)) + val decoded = MessageBodyCodec.decodeServerBody(classic34) as MessageDecodeResult.Decoded + assertEquals(9, (decoded.messages.single() as FrameHeaderMessage).suppressCount) + val short26 = PacketBytes.copyOf(frame(26)) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, MessageBodyCodec.decodeServerBody(short26)) + val wire = PacketCodec.encodeSequenced(1, 0, null, short26) + val packet = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + assertInstanceOf(MessageDecodeResult.Rejected::class.java, MessageBodyCodec.decodeServer(packet)) + } +} From 3822e6cc0a7f042f0740bd83db27a5eab67d8298 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:29:49 +0100 Subject: [PATCH 34/42] Enable protocol 26 demo playback with per session version context --- cake/README.md | 7 ++-- .../src/main/kotlin/org/demoth/cake/Cake.kt | 2 +- .../org/demoth/cake/demo/DemoPlayback.kt | 8 ++--- .../org/demoth/cake/demo/DemoPlaybackTest.kt | 32 ++++++++++++++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/cake/README.md b/cake/README.md index cd520980..bb992d27 100644 --- a/cake/README.md +++ b/cake/README.md @@ -174,7 +174,7 @@ the displayed frame. Map transitions remain in the same recording. An I/O failur stops recording and reports an error while gameplay continues. A recording stopped before its first full snapshot has no playable frames. -Both launchers play classic protocol-32 and protocol-34 client recordings (`.dm2`) +Both launchers play classic protocol-26, protocol-32 and protocol-34 client recordings (`.dm2`) without running a game server. Select a profile containing the recording's maps, models, and sounds, then open the client console: @@ -189,8 +189,9 @@ do not control playback. Opening the console leaves playback running. Base-game and expansion temporary effects use Cake's rendering pipeline; visual fidelity varies by effect family. -Protocol-26 demos shipped with older Quake II releases, extended/rerelease -protocols, and cinematic recordings are unsupported. For a compatible test file, +Protocol-26 recordings use their shorter frame header; missing suppression counts +are treated as zero. Extended/rerelease protocols and cinematic recordings remain +unsupported. For a protocol-34 interoperability test file, record a session in Yamagi using `record example`, then `stop`, and copy its `demos/example.dm2` into the selected profile's game-data directory. diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index 02d5240c..e34cdb6e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -336,7 +336,7 @@ class Cake( if (demoRecording == null) Com.Printf("Not recording a demo\n") else stopRecording() } - Cmd.AddCommand("demomap", "Play a protocol-32/34 demo: demomap ") { args -> + Cmd.AddCommand("demomap", "Play a protocol-26/32/34 demo: demomap ") { args -> if (args.size != 2) { Com.Printf("usage: demomap \n") } else { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt index 20b54a81..d15f0315 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt @@ -2,12 +2,12 @@ package org.demoth.cake.demo import jake2.qcommon.Defines import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoMessageDecoder import jake2.qcommon.network.messages.server.FrameHeaderMessage import jake2.qcommon.network.messages.server.ServerDataMessage import jake2.qcommon.network.messages.server.ServerMessage import jake2.qcommon.network.messages.server.PlayerInfoMessage import jake2.qcommon.network.messages.server.PacketEntitiesMessage -import jake2.qcommon.network.protocol.MessageBodyCodec import jake2.qcommon.network.protocol.MessageDecodeResult import java.io.Closeable import java.io.IOException @@ -17,6 +17,7 @@ class DemoPlayback(private val reader: DemoReader) : Closeable { private data class Pending(val messages: List?, val due: Double, val frameTime: Int?) private var pending: Pending? = null private var closed = false + private val decoder = DemoMessageDecoder() private var serverDataSeen = false private var originFrame: Int? = null private var previousFrame: Int? = null @@ -68,15 +69,12 @@ class DemoPlayback(private val reader: DemoReader) : Closeable { return Pending(null, lastDue + .1, null) } val body = (record as DemoReader.Record.Body).bytes - val messages = when (val result = MessageBodyCodec.decodeServerBody(body)) { + val messages = when (val result = decoder.decode(body)) { is MessageDecodeResult.Decoded -> result.messages is MessageDecodeResult.Rejected -> throw IOException("Invalid demo message at byte ${reader.offset - body.size + result.offset}: ${result.detail}") } if (messages.any { it is ServerDataMessage }) { for (data in messages.filterIsInstance()) { - // Classic protocol 32 uses the same frame/body layouts as 34, including suppressCount. - if (data.protocol != 32 && data.protocol != Defines.PROTOCOL_VERSION) - throw IOException("Unsupported demo protocol ${data.protocol}; expected 32 or ${Defines.PROTOCOL_VERSION}") if (data.playerNumber == -1) throw IOException("Cinematic demos are not supported") if (data.playerNumber !in 0 until Defines.MAX_CLIENTS) throw IOException("Invalid demo player slot ${data.playerNumber}") } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt index 855feabf..f7d17f2f 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt @@ -84,6 +84,36 @@ class DemoPlaybackTest { } } + @Test fun `protocol 26 frames omit suppression while playback preserves delta timing and map version changes`() { + fun oldFrame(number: Int, last: Int): ByteArray { + // svc_frame: two ints followed directly by areaBits; no suppressCount in protocol 26. + val bytes = byteArrayOf(20) + int(number) + int(last) + byteArrayOf(1, 3, + 17, 0, 0, 0, 0, 0, 0, 18, 0, 0) + return int(bytes.size) + bytes + } + playback(bootstrap(26) + oldFrame(100, -1) + oldFrame(101, 100) + + bootstrap(32) + frame(5) + bootstrap(26) + oldFrame(1, 0) + int(-1)).use { demo -> + val messages = mutableListOf() + val dispatch: (List) -> Boolean = { messages += it; true } + assertTrue(demo.update(0f, dispatch)) + assertEquals(9900, demo.presentationTimeMillis) + repeat(3) { assertTrue(demo.update(0f, dispatch)) } + assertEquals(1, messages.filterIsInstance().size) + assertTrue(demo.update(.1f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertFalse(demo.update(.1f, dispatch)) + assertEquals(listOf(26, 32, 26), messages.filterIsInstance().map { it.protocol }) + val frames = messages.filterIsInstance() + assertEquals(listOf(100, 101, 5, 1), frames.map { it.frameNumber }) + assertEquals(listOf(-1, 100, -1, 0), frames.map { it.lastFrame }) + for (frame in frames.filter { it.frameNumber != 5 }) { + assertEquals(0, frame.suppressCount) + assertArrayEquals(byteArrayOf(3), frame.areaBits.toByteArray()) + } + } + } + @Test fun `map changes can switch between supported classic demo versions`() { playback(bootstrap(32) + frame(1000) + bootstrap(34) + frame(5) + int(-1)).use { demo -> val protocols = mutableListOf() @@ -102,7 +132,7 @@ class DemoPlaybackTest { } @Test fun `unsupported protocol and malformed body dispatch no prefix`() { - for (bytes in listOf(bootstrap(26), bootstrap(33), bootstrap(35), + for (bytes in listOf(bootstrap(25), bootstrap(33), bootstrap(35), record(ServerDataMessage(34, 1, true, "baseq2", 0, "test")) + int(2) + byteArrayOf(6, 7))) { playback(bytes).use { demo -> assertThrows(IOException::class.java) { demo.update(0f) { true } } From 484db500e2313649b747c8fd9fcfc8fb8f41c96e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:35:15 +0100 Subject: [PATCH 35/42] Tolerate absent config models in protocol 26 demo playback --- cake/README.md | 5 +- .../demoth/cake/config/GameConfiguration.kt | 17 ++++- .../demoth/cake/stages/ingame/Game3dScreen.kt | 1 + .../cake/assets/CakeFileResolverTest.kt | 63 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/cake/README.md b/cake/README.md index bb992d27..722328a6 100644 --- a/cake/README.md +++ b/cake/README.md @@ -190,7 +190,10 @@ Base-game and expansion temporary effects use Cake's rendering pipeline; visual fidelity varies by effect family. Protocol-26 recordings use their shorter frame header; missing suppression counts -are treated as zero. Extended/rerelease protocols and cinematic recordings remain +are treated as zero. Missing MD2/SP2 config models in protocol-26 demos are warned +about and skipped, since early demos may reference models absent from later game +data. Maps and present-but-malformed assets still require successful loading. +Extended/rerelease protocols and cinematic recordings remain unsupported. For a protocol-34 interoperability test file, record a session in Yamagi using `record example`, then `stop`, and copy its `demos/example.dm2` into the selected profile's game-data directory. diff --git a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt index d18901c3..61e9baa1 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt @@ -85,6 +85,9 @@ class GameConfiguration( */ var layout: String = "" + /** Protocol-26 demo playback may reference models absent from later game data (Yamagi R_RegisterModel). */ + var allowMissingModelConfigs: Boolean = false + private val configStrings = Array(size) { null } private val trackedLoadedAssets: MutableMap> = mutableMapOf() private val failedAssets: MutableMap = mutableMapOf() @@ -330,7 +333,8 @@ class GameConfiguration( val config = configStrings[i] ?: continue if (!loadConfigResource(i, config)) { val modelPath = config.value - if (modelPath.isNotBlank() && !modelPath.startsWith("*") && !modelPath.startsWith("#")) { + if (modelPath.isNotBlank() && !modelPath.startsWith("*") && !modelPath.startsWith("#") && + modelPath !in failedAssets) { Com.Warn("Failed to load model data for config $modelPath") } } @@ -413,6 +417,17 @@ class GameConfiguration( if (modelPath.isBlank() || modelPath.startsWith("*") || modelPath.startsWith("#")) { return false } + if (allowMissingModelConfigs && + (modelPath.endsWith(".md2", ignoreCase = true) || modelPath.endsWith(".sp2", ignoreCase = true)) && + !assetExists(modelPath)) { + // Yamagi registers these with crash=false. Keep the config/index, but omit absent geometry. + config.resource = null + if (modelPath !in failedAssets) { + failedAssets[modelPath] = "Model absent from game data during old-demo playback" + Com.Warn("Skipping missing demo model: $modelPath") + } + return false + } return when { modelPath.endsWith(".md2", ignoreCase = true) -> { val md2Asset = acquireAsset(modelPath) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index e87a5f8a..5d2123b4 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -1058,6 +1058,7 @@ class Game3dScreen( */ override fun processServerDataMessage(msg: ServerDataMessage) { recordingServerData = msg + gameConfig.allowMissingModelConfigs = demoPlayback && msg.protocol == 26 levelString = msg.levelString // player slot used by prediction/entity visibility/HUD highlighting. gameConfig.playerConfiguration.playerIndex = msg.playerNumber diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt index 1e394a4d..1dc69fba 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt @@ -2,6 +2,12 @@ package org.demoth.cake.assets import com.badlogic.gdx.ApplicationListener import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.GdxRuntimeException import com.badlogic.gdx.backends.headless.HeadlessApplication import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration import org.junit.jupiter.api.AfterAll @@ -11,6 +17,10 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertThrows import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.assets.md2.Md2Asset +import org.demoth.cake.assets.bsp.BspMapAsset +import jake2.qcommon.Defines import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -31,6 +41,59 @@ class CakeFileResolverTest { @TempDir lateinit var temp: Path + @Test + fun oldDemoModelPolicyKeepsMissingModelIndicesButDoesNotRelaxMapOrResolverRequirements() { + setFsDebugLoaders("0") + val resolver = CakeFileResolver(temp.toString()) + val assets = AssetManager(resolver, false) + assets.setLoader(BspMapAsset::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): BspMapAsset = + error("A missing map must fail during resolution") + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): com.badlogic.gdx.utils.Array>? = null + }) + try { + val config = GameConfiguration(assets).apply { allowMissingModelConfigs = true } + for ((index, path) in listOf("models/removed.md2", "sprites/removed.sp2").withIndex()) { + val slot = Defines.CS_MODELS + index + 2 + config.applyConfigString(slot, path, loadResource = true) + assertEquals(path, config.getConfigValue(slot)) + assertNull(config[slot]?.resource) + } + assertEquals(0, assets.loadedAssets) + assertThrows(MissingResourceException::class.java) { resolver.resolve("models/removed.md2") } + config.applyConfigString(Defines.CS_MODELS + 1, "maps/required.bsp") + val missingMap = assertThrows(RuntimeException::class.java) { config.loadMapAsset() } + assertTrue(generateSequence(missingMap) { it.cause }.any { + it is MissingResourceException && it.path == "maps/required.bsp" + }) + } finally { assets.dispose() } + } + + @Test + fun missingModelToleranceIsOptInAndPresentButMalformedModelsStillFail() { + setFsDebugLoaders("0") + createFile("baseq2/models/malformed.md2", byteArrayOf(1)) + val resolver = CakeFileResolver(temp.toString()) + val assets = AssetManager(resolver, false) + assets.setLoader(Md2Asset::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): Md2Asset = + throw GdxRuntimeException("Synthetic malformed model") + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): com.badlogic.gdx.utils.Array>? = null + }) + try { + val config = GameConfiguration(assets) + val missing = assertThrows(RuntimeException::class.java) { + config.applyConfigString(Defines.CS_MODELS + 2, "models/removed.md2", loadResource = true) + } + assertTrue(generateSequence(missing) { it.cause }.any { it is MissingResourceException }) + config.allowMissingModelConfigs = true + val malformed = assertThrows(RuntimeException::class.java) { + config.applyConfigString(Defines.CS_MODELS + 3, "models/malformed.md2", loadResource = true) + } + assertTrue(generateSequence(malformed) { it.cause }.any { it.message == "Synthetic malformed model" }) + } finally { assets.dispose() } + } + @Test fun contentSnapshotListsWinnersAndChangesWithProfileWithoutBundledFallbacks() { createFile("baseq2/shared.cfg", "base".toByteArray()) From 258fbc899839f6b5b237383353ace2eff1c0a7f6 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:41:34 +0100 Subject: [PATCH 36/42] Read bounded demo startup metadata without loading assets --- .../org/demoth/cake/demo/DemoMetadata.kt | 46 +++++++++++++++ .../org/demoth/cake/demo/DemoMetadataTest.kt | 56 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt new file mode 100644 index 00000000..933a9f6f --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt @@ -0,0 +1,46 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoMessageDecoder +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageDecodeResult +import java.io.IOException +import java.io.InputStream + +/** Startup information only; reading metadata neither loads game assets nor scans the full recording. */ +data class DemoMetadata(val protocol: Int, val gameDirectory: String, val levelName: String, val mapPath: String?) { + companion object { + const val MAX_STARTUP_RECORDS = 64 + + /** Owns and closes [input] on success, rejection or reaching the bounded startup budget. */ + fun read(input: InputStream): DemoMetadata = DemoReader(input).use { reader -> + val decoder = DemoMessageDecoder() + var server: ServerDataMessage? = null + var map: String? = null + fun result(): DemoMetadata { + val data = server ?: throw IOException("Demo has no server data in its startup records") + return DemoMetadata(data.protocol, data.gameName.ifEmpty { "baseq2" }, data.levelString, map) + } + repeat(MAX_STARTUP_RECORDS) { + when (val record = reader.read()) { + DemoReader.Record.End -> return result() + is DemoReader.Record.Body -> { + val messages = when (val decoded = decoder.decode(record.bytes)) { + is MessageDecodeResult.Decoded -> decoded.messages + is MessageDecodeResult.Rejected -> throw IOException(decoded.detail) + } + for (message in messages) when (message) { + is ServerDataMessage -> server = message + is ConfigStringMessage -> if (message.index == Defines.CS_MODELS + 1) map = message.config + else -> Unit + } + if (server != null && (map != null || messages.any { it is FrameHeaderMessage || it is StuffTextMessage })) + return result() + } + } + } + result() + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt new file mode 100644 index 00000000..14672ea1 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt @@ -0,0 +1,56 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoMetadataTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + private fun record(vararg messages: ServerMessage): ByteArray { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + } + private fun server(protocol: Int) = ServerDataMessage(protocol, 1, true, "", 0, "Installation") + + @Test fun `metadata reads classic startup versions and map across records then leaves gameplay unread`() { + for (protocol in listOf(26, 32, 34)) { + val startup = record(server(protocol)) + record(ConfigStringMessage(Defines.CS_MODELS + 1, "maps/base1.bsp")) + var closes = 0 + val input = object : ByteArrayInputStream(startup + byteArrayOf(1, 2, 3)) { + override fun close() { closes++ } + } + assertEquals(DemoMetadata(protocol, "baseq2", "Installation", "maps/base1.bsp"), DemoMetadata.read(input)) + assertEquals(3, input.available()) + assertEquals(1, closes) + } + } + + @Test fun `header without map can still describe a demo when precache or terminator is reached`() { + for (ending in listOf(record(StuffTextMessage("precache\n")), int(-1))) { + assertEquals(DemoMetadata(34, "baseq2", "Installation", null), + DemoMetadata.read(ByteArrayInputStream(record(server(34)) + ending))) + } + } + + @Test fun `empty records cannot cause an unbounded metadata scan`() { + val input = ByteArrayInputStream(ByteArray(4 * (DemoMetadata.MAX_STARTUP_RECORDS + 10))) + assertThrows(IOException::class.java) { DemoMetadata.read(input) } + assertEquals(40, input.available()) + } + + @Test fun `malformed or unsupported startup fails and closes the stream`() { + for (bytes in listOf(byteArrayOf(1, 2), int(-1), record(server(35)), record(server(34)) + int(-2))) { + var closed = false + val input = object : ByteArrayInputStream(bytes) { override fun close() { closed = true } } + assertThrows(IOException::class.java) { DemoMetadata.read(input) } + assertTrue(closed) + } + } +} From 0f15390458ec6d5c3bf7b762dcc7c4a2e88d3d3b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:42:16 +0100 Subject: [PATCH 37/42] Expose profile recordings as content browser entries --- .../kotlin/org/demoth/cake/demo/DemoFiles.kt | 43 +++++++++++++++++++ .../org/demoth/cake/demo/DemoFilesTest.kt | 26 +++++++++++ .../main/java/jake2/qcommon/vfs/VfsLayer.java | 1 + 3 files changed, 70 insertions(+) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt index 55502007..78ddb671 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt @@ -3,11 +3,19 @@ package org.demoth.cake.demo import jake2.qcommon.vfs.VfsOpenOptions import jake2.qcommon.vfs.VfsWriteOptions import jake2.qcommon.vfs.WritableFileSystem +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.vfs.VfsLayer +import jake2.qcommon.vfs.VfsSource +import jake2.qcommon.vfs.VfsSourceType import java.io.FilterInputStream import java.io.FilterOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.util.Locale /** Profile-local recordings take precedence over installed/archived demos with the same logical name. */ internal class DemoFiles( @@ -16,6 +24,41 @@ internal class DemoFiles( ) { data class Output(val path: String, val stream: OutputStream) + /** Fresh recordings overlay the installed snapshot; no package extraction or media loading. */ + fun entries(installed: List, caseSensitive: Boolean = false): List { + val root = Path.of(writable().writeRoot()).toAbsolutePath().normalize() + val directory = root.resolve("demos") + val merged = installed.associateByTo(linkedMapOf()) { it.normalizedPath } + if (Files.isDirectory(directory)) Files.walk(directory).use { paths -> + paths.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) && it.fileName.toString().endsWith(".dm2", true) } + .sorted().forEach { file -> + val display = root.relativize(file).toString().replace('\\', '/') + val logical = if (caseSensitive) display else display.lowercase(Locale.ROOT) + merged[logical] = VfsEntry(logical, display, VfsLayer.PROFILE_LOOSE, 0, + VfsSource(VfsSourceType.DIRECTORY, root, display, null, false, false), + Files.size(file), Files.getLastModifiedTime(file).toMillis()) + } + } + return merged.values.sortedBy { it.normalizedPath } + } + + /** Open the selected winner exactly, including mixed-case saved names and demos outside demos/. */ + fun open(entry: VfsEntry): InputStream { + if (entry.layer != VfsLayer.PROFILE_LOOSE) + return openContent(entry.normalizedPath) ?: throw IOException("Demo not found: ${entry.displayPath}") + val files = writable() + val root = Path.of(files.writeRoot()).toAbsolutePath().normalize() + if (entry.source.containerPath != root) throw IOException("Demo belongs to another profile") + val handle = files.openReadReal(entry.displayPath, VfsOpenOptions.DEFAULT).value + ?: throw IOException("Demo not found: ${entry.displayPath}") + try { + return object : FilterInputStream(handle.inputStream()) { override fun close() = handle.close() } + } catch (failure: Exception) { + try { handle.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + fun create(name: String): Output { val path = demoLogicalPath(name) ?: throw IOException("Invalid demo path: $name") val files = writable() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt index 3b3df191..d17cf57a 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt @@ -1,6 +1,10 @@ package org.demoth.cake.demo import jake2.qcommon.vfs.DefaultWritableFileSystem +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.vfs.VfsLayer +import jake2.qcommon.vfs.VfsSource +import jake2.qcommon.vfs.VfsSourceType import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -50,4 +54,26 @@ class DemoFilesTest { } assertEquals(0L, Files.list(root).use { it.count() }) } + + @Test fun `browser snapshots overlay installed demos refresh recordings and open the exact selected source`() { + var profile = "first" + val files = DemoFiles({ DefaultWritableFileSystem(root.resolve(profile)) }, { ByteArrayInputStream(byteArrayOf(7)) }) + val installed = VfsEntry("demos/take.dm2", "demos/take.dm2", VfsLayer.BASE_PACK, 0, + VfsSource(VfsSourceType.PACKAGE_ENTRY, root.resolve("pak0.pak"), "demos/take.dm2", "pak", true, false), 12, 0) + assertEquals(listOf(installed), files.entries(listOf(installed))) + files.create("Take").stream.use { it.write(8) } + files.create("series/new").stream.use { it.write(9) } + val entries = files.entries(listOf(installed)) + assertEquals(listOf("demos/series/new.dm2", "demos/take.dm2"), entries.map { it.normalizedPath }) + val selected = entries.last() + assertEquals(VfsLayer.PROFILE_LOOSE, selected.layer) + assertEquals("demos/Take.dm2", selected.displayPath) + assertEquals(1L, selected.size) + files.open(selected).use { assertEquals(8, it.read()) } + files.open(installed).use { assertEquals(7, it.read()) } + assertEquals(3, files.entries(listOf(installed), caseSensitive = true).size) + profile = "second" + assertEquals(listOf(installed), files.entries(listOf(installed))) + assertThrows(IOException::class.java) { files.open(selected) } + } } diff --git a/qcommon/src/main/java/jake2/qcommon/vfs/VfsLayer.java b/qcommon/src/main/java/jake2/qcommon/vfs/VfsLayer.java index 7e439924..27b11c3a 100644 --- a/qcommon/src/main/java/jake2/qcommon/vfs/VfsLayer.java +++ b/qcommon/src/main/java/jake2/qcommon/vfs/VfsLayer.java @@ -4,6 +4,7 @@ * Ordered source layers for virtual path resolution. */ public enum VfsLayer { + PROFILE_LOOSE, MOD_LOOSE, MOD_PACK, BASE_LOOSE, From 8adab9569603abba0dcb9eeab78ff10d1495c0e3 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:44:08 +0100 Subject: [PATCH 38/42] Show selected demo startup metadata in the content browser --- cake/README.md | 6 ++++++ .../src/main/kotlin/org/demoth/cake/Cake.kt | 5 ++++- .../demoth/cake/stages/ContentBrowserStage.kt | 21 ++++++++++++++++++- .../org/demoth/cake/stages/MenuStages.kt | 10 ++++++--- .../org/demoth/cake/ui/ContentBrowser.kt | 8 +++++-- .../main/kotlin/org/demoth/cake/ui/README.md | 1 + 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/cake/README.md b/cake/README.md index 722328a6..f0a404f4 100644 --- a/cake/README.md +++ b/cake/README.md @@ -189,6 +189,12 @@ do not control playback. Opening the console leaves playback running. Base-game and expansion temporary effects use Cake's rendering pipeline; visual fidelity varies by effect family. +The content browser includes installed demos and profile-local recordings. Selecting +a `.dm2` file shows its protocol, game directory, level and available map path, +alongside file size and source. Local recordings take precedence over installed +demos with the same path. Playback still starts through `demomap`; browser Open +and return navigation are the next integration slice. + Protocol-26 recordings use their shorter frame header; missing suppression counts are treated as zero. Missing MD2/SP2 config models in protocol-26 demos are warned about and skipped, since early demos may reference models absent from later game diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index e34cdb6e..95b05504 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -19,6 +19,7 @@ import jake2.qcommon.network.demo.DemoReader import org.demoth.cake.demo.DemoPlayback import org.demoth.cake.demo.DemoRecording import org.demoth.cake.demo.DemoFiles +import org.demoth.cake.demo.DemoMetadata import jake2.qcommon.network.demo.DemoWriter import jake2.qcommon.network.protocol.PacketEnvelope import org.demoth.cake.demo.demoLogicalPath @@ -251,7 +252,9 @@ class Cake( readSavePreview = { profileId, slot -> if (activeGameProfile?.id == profileId) host.readSavePreview(slot) else null }, - activeProfileId = { activeGameProfile?.id }) + activeProfileId = { activeGameProfile?.id }, + listContentEntries = { demoFiles.entries(fileResolver.listContentEntries(), fileResolver.caseSensitive) }, + readDemoMetadata = { entry -> DemoMetadata.read(demoFiles.open(entry)) }) rebuildContentStyledMenuStages(initialStyle) // todo: gather all early logging (which is generated before the console is created) // and put into the console when it's ready diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt index 0b7e2971..c4ba2524 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt @@ -9,6 +9,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.vfs.VfsEntry +import org.demoth.cake.demo.DemoMetadata import org.demoth.cake.ui.ContentBrowser import org.demoth.cake.ui.ContentBrowserStyles import org.demoth.cake.ui.GameUiStyle @@ -25,11 +26,13 @@ class ContentBrowserStage( private val thumbnails: ImageThumbnails, previewAssets: AssetManager, loadSound: (VfsEntry) -> Sound, + private val readDemoMetadata: (VfsEntry) -> DemoMetadata? = { null }, ) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { private val styles = ContentBrowserStyles.fromMenu(style.menuWidgets) private val media = ContentPreview(styles, thumbnails, previewAssets, loadSound) private val title = Label("Content browser", styles.label) private val content = Container().fill() + private val demoDescriptions = mutableMapOf() init { addActor(Table().apply { @@ -51,7 +54,22 @@ class ContentBrowserStage( Label(message ?: "No content found in this profile.", styles.label) } else { ContentBrowser(entries, profileName, styles, previewSelection = media::open, loadThumbnail = thumbnails::load, - clearThumbnails = thumbnails::clear, onSelect = media::select, showSource = true) + clearThumbnails = thumbnails::clear, onSelect = media::select, showSource = true, + selectionDetails = ::describeDemo, selectionDetailLines = 2) + } + } + + private fun describeDemo(entry: VfsEntry): String? { + if (!entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) return null + return demoDescriptions.getOrPut(entry) { + try { + val info = readDemoMetadata(entry) + if (info == null) "Demo metadata unavailable." else + "Demo: protocol ${info.protocol} | Game: ${info.gameDirectory}\nLevel: ${info.levelName}" + + (info.mapPath?.let { " | Map: $it" } ?: "") + } catch (error: Exception) { + "Could not read demo: ${error.message}" + } } } @@ -75,6 +93,7 @@ class ContentBrowserStage( content.actor = null media.clear() media.contentPaths = emptyList() + demoDescriptions.clear() thumbnails.clear() } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt index 79c60130..0feb2b46 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt @@ -6,6 +6,8 @@ import com.badlogic.gdx.assets.loaders.FileHandleResolver import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.utils.Disposable import jake2.qcommon.Com +import jake2.qcommon.vfs.VfsEntry +import org.demoth.cake.demo.DemoMetadata import ktx.assets.TextAssetLoader import org.demoth.cake.assets.* import org.demoth.cake.assets.bsp.BspLoader @@ -28,6 +30,8 @@ class MenuStages( private val fileResolver: CakeFileResolver, private val showHosting: Boolean, private val readSavePreview: (String, String) -> ByteArray? = { _, _ -> null }, + private val listContentEntries: () -> List = fileResolver::listContentEntries, + private val readDemoMetadata: (VfsEntry) -> DemoMetadata? = { null }, private val activeProfileId: () -> String?, ) : Disposable { private val stages = linkedMapOf() @@ -62,11 +66,11 @@ class MenuStages( MenuScreen.OPTIONS_SECTION -> OptionsSectionStage(viewport, menuEventBus, style) MenuScreen.CONTENT_BROWSER -> ContentBrowserStage(viewport, menuEventBus, style, ImageThumbnails(assetManager, FileHandleResolver(fileResolver::resolveContent)), - createPreviewAssets()) { entry -> + createPreviewAssets(), loadSound = { entry -> val file = requireNotNull(fileResolver.resolveContent(entry.normalizedPath)) { "Missing profile sound" } // Preview sounds are owned separately from the shared asset manager. ConvertingSoundLoader(fileResolver).loadSync(assetManager, entry.normalizedPath, file, null) - } + }, readDemoMetadata = readDemoMetadata) } } if (activeScreen == MenuScreen.CONTENT_BROWSER) refreshContentBrowser() @@ -119,7 +123,7 @@ class MenuStages( return } try { - contentBrowserStage.show(fileResolver.listContentEntries(), profileId) + contentBrowserStage.show(listContentEntries(), profileId) } catch (error: Exception) { Com.Warn("Could not list profile content: ${error.message}") contentBrowserStage.show(emptyList(), profileId, "Could not list profile content.") diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt index 610cb744..0f48465d 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt @@ -53,6 +53,7 @@ internal class ContentDirectory(val path: String, val name: String) { * [loadThumbnail] borrows a texture from the host for this folder's lifetime. * [clearThumbnails] releases the folder's textures when navigating to another folder. * [onSelect] may supply a borrowed inline preview for the selected tile. + * [selectionDetails] supplies optional metadata in the footer; reserve its height with [selectionDetailLines]. */ class ContentBrowser( entries: List, rootName: String, private val styles: ContentBrowserStyles, @@ -61,6 +62,8 @@ class ContentBrowser( private val clearThumbnails: () -> Unit = {}, private val onSelect: (VfsEntry?) -> Actor? = { null }, private val showSource: Boolean = false, + private val selectionDetails: (VfsEntry) -> String? = { null }, + private val selectionDetailLines: Int = 0, ) : Table() { /** Preserve the standalone viewer's native engine styling and callback contract. */ constructor( @@ -138,7 +141,7 @@ class ContentBrowser( add(content).grow().row() // Reserve metadata lines before selection, keeping tiles under the pointer. add(selectionInfo).minWidth(0f).height(selectionInfo.style.font.data.lineHeight * - if (showSource) 3f else 2f).pad(8f).row() + ((if (showSource) 3f else 2f) + selectionDetailLines)).pad(8f).row() } add(SplitPane(scrollPane(tree), listing, false, styles.splitPane).apply { splitAmount = 0.3f }).grow() @@ -266,8 +269,9 @@ class ContentBrowser( override fun clicked(event: InputEvent, x: Float, y: Float) { tiles.forEach { it.isChecked = it === tile } selectionInfo.setText(entry?.let { - "${it.displayPath}\n${it.size} bytes" + if (showSource) + val basic = "${it.displayPath}\n${it.size} bytes" + if (showSource) "\nSource: ${it.layer} / ${it.source.containerPath.fileName}" else "" + selectionDetails(it)?.let { details -> "$basic\n$details" } ?: basic } ?: "${directory!!.path}/") if (tapCount == 1) { clearAudition() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md index 0edc93e0..c76e2f0f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md @@ -43,6 +43,7 @@ artwork and atlas lines; tree slots match the 8-unit game theme. | Component | Responsibility | |---|---| | `ContentBrowserStage` / `ContentBrowser` | Client layout; shared VFS browsing and back navigation. Hosts supply the root action. | +| `DemoFiles` / `DemoMetadata` | Merge profile recordings with installed demo entries and read bounded startup metadata on selection, without extracting packages or loading game assets. | | `ContentBrowserStyles` | Resolved menu styles; standalone Skin adapter preserves viewer sizing. | | `FileResourcePreview` / `CinematicPreview` | Shared read-only text and CIN widgets; owns the palette reference, cinematic texture/pixmap and audio device. Closing detaches the widget and releases resources. | | `ImageThumbnails` | Progressive visible PCX/WAL/TGA and first-frame CIN loading; owns folder-scoped textures, released on folder change, exit, or profile change. | From b2f50ab3e36ba56266144edfb0e76a1b26dc7b09 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:54:01 +0100 Subject: [PATCH 39/42] Keep content browser bookmarks independent of actors and profile changes --- .../cake/ui/ContentBrowserNavigation.kt | 27 +++++++++++++++++++ .../cake/ui/ContentBrowserNavigationTest.kt | 16 +++++++++++ 2 files changed, 43 insertions(+) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt new file mode 100644 index 00000000..41c08c9e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt @@ -0,0 +1,27 @@ +package org.demoth.cake.ui + +/** Value state survives preview cleanup and menu/style rebuilds without retaining actors or resources. */ +data class ContentBrowserLocation( + val directory: String = "", + val selectedPath: String? = null, + val scrollY: Float = 0f, + val thumbnailWidth: Float = 128f, +) + +class ContentBrowserNavigation { + private var profile: String? = null + private var location: ContentBrowserLocation? = null + + fun forProfile(profileId: String): ContentBrowserLocation? { + if (profile != profileId) { + profile = profileId + location = null + } + return location + } + + fun remember(profileId: String, value: ContentBrowserLocation) { + profile = profileId + location = value + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt new file mode 100644 index 00000000..4479e1d6 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt @@ -0,0 +1,16 @@ +package org.demoth.cake.ui + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ContentBrowserNavigationTest { + @Test fun `bookmark retains only value state for the same profile and resets on profile change`() { + val navigation = ContentBrowserNavigation() + assertNull(navigation.forProfile("base")) + val location = ContentBrowserLocation("demos/series", "demos/series/test.dm2", 250f, 160f) + navigation.remember("base", location) + assertEquals(location, navigation.forProfile("base")) + assertNull(navigation.forProfile("rogue")) + assertNull(navigation.forProfile("base")) + } +} From 1c67a7a5852ebecdca4750f59f00593dd4cc7ea1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Fri, 30 Oct 2026 00:55:25 +0100 Subject: [PATCH 40/42] Launch browser demos and restore navigation after playback --- cake/README.md | 13 ++-- .../src/main/kotlin/org/demoth/cake/Cake.kt | 75 +++++++++++++----- .../demoth/cake/stages/ContentBrowserStage.kt | 19 ++++- .../org/demoth/cake/stages/MenuStages.kt | 12 ++- .../org/demoth/cake/ui/ContentBrowser.kt | 78 ++++++++++++++----- .../main/kotlin/org/demoth/cake/ui/README.md | 1 + .../org/demoth/cake/ui/menu/MenuController.kt | 6 ++ .../org/demoth/cake/ui/menu/MenuIntent.kt | 2 + .../org/demoth/cake/ui/menu/MenuSignal.kt | 2 + .../org/demoth/cake/ui/MenuStagesGlTest.kt | 65 ++++++++++++++++ .../demoth/cake/ui/menu/MenuControllerTest.kt | 32 ++++++++ 11 files changed, 259 insertions(+), 46 deletions(-) diff --git a/cake/README.md b/cake/README.md index f0a404f4..6fad4879 100644 --- a/cake/README.md +++ b/cake/README.md @@ -181,10 +181,10 @@ models, and sounds, then open the client console: - `demomap example` — play the active profile's saved `demos/example.dm2`, falling back to game data. - `demomap demos/folder/example.dm2` — play a nested demo, including archive entries. - `demopause` — toggle pause; opening the menu or suspending the application also pauses playback. -- `demostop` or `disconnect` — stop and return to the console. +- `demostop` or `disconnect` — stop and return to the browser for browser-started playback, or the console otherwise. -EOF and playback errors return to the console. Starting another demo replaces the -current session. The camera follows the recorded player; movement and mouse look +EOF and playback errors return to the starting browser or console. Browser errors +appear above the retained file listing. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look do not control playback. Opening the console leaves playback running. Base-game and expansion temporary effects use Cake's rendering pipeline; visual fidelity varies by effect family. @@ -192,8 +192,11 @@ visual fidelity varies by effect family. The content browser includes installed demos and profile-local recordings. Selecting a `.dm2` file shows its protocol, game directory, level and available map path, alongside file size and source. Local recordings take precedence over installed -demos with the same path. Playback still starts through `demomap`; browser Open -and return navigation are the next integration slice. +demos with the same path. Double-click or press Enter to start the selected +demo. Playback returns to the same folder, selected file, scroll position and +thumbnail size on completion, stop or error, including after menu-theme changes. +Escape opens the menu and pauses playback; close the menu to resume. The menu’s +Disconnect action stops playback and returns to the browser. Protocol-26 recordings use their shorter frame header; missing suppression counts are treated as zero. Missing MD2/SP2 config models in protocol-26 demos are warned diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index 95b05504..387a7714 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -15,6 +15,8 @@ import com.badlogic.gdx.scenes.scene2d.ui.Skin import com.badlogic.gdx.utils.ScreenUtils import com.badlogic.gdx.utils.viewport.ScreenViewport import java.io.IOException +import java.io.InputStream +import jake2.qcommon.vfs.VfsEntry import jake2.qcommon.network.demo.DemoReader import org.demoth.cake.demo.DemoPlayback import org.demoth.cake.demo.DemoRecording @@ -140,6 +142,8 @@ class Cake( // Connection and download protocol state. private var demoRecording: DemoRecording? = null private val demoFiles = DemoFiles(profilePersistence::writable, fileResolver::openContent) + private data class DemoBrowserReturn(val profileId: String, val gameDirectory: String?) + private var demoBrowserReturn: DemoBrowserReturn? = null private var demoPlayback: DemoPlayback? = null private var demoPaused = false private var networkState = DISCONNECTED @@ -349,8 +353,8 @@ class Cake( Cmd.AddCommand("demopause", "Pause or resume demo playback") { if (demoPlayback != null) demoPaused = !demoPaused } - Cmd.AddCommand("demostop", "Stop demo playback and return to the console") { - if (demoPlayback != null) dropToConsole() + Cmd.AddCommand("demostop", "Stop demo playback and return to the browser or console") { + if (demoPlayback != null) finishSession() } Cmd.AddCommand("connect", "Connect to a remote server") { @@ -420,7 +424,9 @@ class Cake( network.sendConnectionless(to, ConnectionlessCommand.rcon, message) } - Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { disconnect() } + Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { + if (demoPlayback != null) finishSession() else disconnect() + } Cmd.AddCommand("menu_loadgame", "Open the Load Game menu") { menuEventBus.postIntent(MenuIntent.OpenLoadGame) } @@ -589,12 +595,32 @@ class Cake( Com.Warn("${error.message}\n") return } + beginDemo(input, path) + } + + private fun startDemo(profileId: String, entry: VfsEntry) { + if (profileId != activeGameProfile?.id) return + val origin = DemoBrowserReturn(profileId, fileResolver.gamemod) + val input = try { + demoFiles.open(entry) + } catch (error: Exception) { + val message = "Could not open demo: ${error.message}" + Com.Warn("$message\n") + menuStages.showContentBrowser(message) + return + } + beginDemo(input, entry.displayPath, origin) + } + + private fun beginDemo(input: InputStream, path: String, origin: DemoBrowserReturn? = null) { try { disconnect() } catch (error: RuntimeException) { - input.close() + try { input.close() } catch (closing: Exception) { error.addSuppressed(closing) } throw error } + menuStages.hideContentPreviews() + demoBrowserReturn = origin demoPlayback = DemoPlayback(DemoReader(input)) demoPaused = false networkState = CONNECTED @@ -614,17 +640,17 @@ class Cake( if (demoPlayback !== playback) return if (!running) { Com.Printf("Demo completed\n") - dropToConsole() + finishSession() return } game3dScreen?.setDemoTime(playback.presentationTimeMillis, playback.interpolationSeconds) } catch (error: IOException) { Com.Warn("Demo playback failed: ${error.message}\n") - dropToConsole() + finishSession("Demo playback failed: ${error.message}") } catch (error: RuntimeException) { // Asset loading and applying recorded state can also reject structurally valid bodies. Com.Warn("Demo playback failed: ${error.message}\n") - dropToConsole() + finishSession("Demo playback failed: ${error.message}") } } @@ -650,6 +676,7 @@ class Cake( private fun resetClientConnection() { stopRecording() val playback = demoPlayback + demoBrowserReturn = null val wasDemo = playback != null demoPlayback = null demoPaused = false @@ -680,7 +707,7 @@ class Cake( private fun handleMissingResourceFailure(error: MissingResourceException) { Com.Warn("${error.message}\n") - dropToConsole() + finishSession() } private fun findMissingResourceFailure(error: Throwable): MissingResourceException? { @@ -694,11 +721,22 @@ class Cake( return null } - private fun dropToConsole() { + /** Retire the session and return to its browser origin, or to the console for other sessions. */ + private fun finishSession(message: String? = null) { + val origin = demoBrowserReturn.takeIf { demoPlayback != null } disconnect() - menuVisible = false - consoleVisible = true - consoleStage.focus() + if (origin != null && origin.profileId == activeGameProfile?.id) { + fileResolver.gamemod = origin.gameDirectory + refreshContentStyledMenus() + menuStages.showContentBrowser(message) + menuEventBus.postIntent(MenuIntent.OpenContentBrowser) + menuVisible = true + consoleVisible = false + } else { + menuVisible = false + consoleVisible = true + consoleStage.focus() + } updateInputHandlers(consoleVisible, menuVisible) } @@ -828,7 +866,7 @@ class Cake( consoleVisible = false } if (localConnection && host.state == HostState.STOPPED) { - dropToConsole() + finishSession() return } game3dScreen?.updateLocalInput(deltaSeconds) @@ -895,7 +933,7 @@ class Cake( (error is jake2.qcommon.longjmpException && host.availability == HostAvailability.AVAILABLE)) { val startingFromMenu = awaitingNewGame Com.Warn("Local server stopped: ${error.message}\n") - dropToConsole() + finishSession() if (startingFromMenu) { menuEventBus.postIntent(MenuIntent.HostingFailed(error.message ?: "New Game failed")) menuVisible = true @@ -1139,6 +1177,7 @@ class Cake( consoleVisible = false updateInputHandlers(consoleVisible, menuVisible) } + is MenuSignal.PlayDemo -> startDemo(signal.profileId, signal.entry) is MenuSignal.OpenUiSample -> openUiSample(signal.game) MenuSignal.CloseUiSample -> closeUiSample() is MenuSignal.CaptureUiSample -> { @@ -1242,7 +1281,7 @@ class Cake( } is MessageDecodeResult.Rejected -> { Com.Warn("Bad server payload at ${body.offset}: ${body.detail}\n") - dropToConsole() + finishSession() } } } //else wasn't accepted for some reason @@ -1269,7 +1308,7 @@ class Cake( is DisconnectMessage -> { Com.Printf("Server disconnected\n") onParsed(snapshotAccepted) - dropToConsole() + finishSession() return } @@ -1335,7 +1374,7 @@ class Cake( CakeDownloadSession.Result.Continue -> Unit is CakeDownloadSession.Result.RetryPrecache -> Cbuf.AddText("${StringCmdMessage.PRECACHE} ${result.spawnCount}\n") - CakeDownloadSession.Result.MissingOnServer -> dropToConsole() + CakeDownloadSession.Result.MissingOnServer -> finishSession() } } @@ -1781,7 +1820,7 @@ class Cake( override fun canDisconnect(): Boolean = networkState != DISCONNECTED || host.state != HostState.STOPPED override fun disconnect() { - this@Cake.disconnect() + if (demoPlayback != null) finishSession() else this@Cake.disconnect() } override fun listProfileIds(): List = listProfileIdsForMenu() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt index c4ba2524..509ace1f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt @@ -11,6 +11,7 @@ import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.vfs.VfsEntry import org.demoth.cake.demo.DemoMetadata import org.demoth.cake.ui.ContentBrowser +import org.demoth.cake.ui.ContentBrowserNavigation import org.demoth.cake.ui.ContentBrowserStyles import org.demoth.cake.ui.GameUiStyle import org.demoth.cake.ui.ContentPreview @@ -27,12 +28,15 @@ class ContentBrowserStage( previewAssets: AssetManager, loadSound: (VfsEntry) -> Sound, private val readDemoMetadata: (VfsEntry) -> DemoMetadata? = { null }, + private val navigation: ContentBrowserNavigation = ContentBrowserNavigation(), ) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { private val styles = ContentBrowserStyles.fromMenu(style.menuWidgets) private val media = ContentPreview(styles, thumbnails, previewAssets, loadSound) private val title = Label("Content browser", styles.label) private val content = Container().fill() + private val notice = Container().fillX() private val demoDescriptions = mutableMapOf() + private var profileName: String? = null init { addActor(Table().apply { @@ -42,12 +46,16 @@ class ContentBrowserStage( add(title).growX().left() add(createBackButton(style = styles.button)).right() }).growX().padBottom(8f).row() + add(notice).growX().row() add(content).grow() }) } fun show(entries: List, profileName: String, message: String? = null) { hide() + this.profileName = profileName + val location = navigation.forProfile(profileName) + notice.actor = message?.let { Label(it, styles.label).apply { setWrap(true) } } media.contentPaths = entries.map { it.normalizedPath } title.setText("Content: $profileName (${entries.size} files)") content.actor = if (entries.isEmpty()) { @@ -55,7 +63,13 @@ class ContentBrowserStage( } else { ContentBrowser(entries, profileName, styles, previewSelection = media::open, loadThumbnail = thumbnails::load, clearThumbnails = thumbnails::clear, onSelect = media::select, showSource = true, - selectionDetails = ::describeDemo, selectionDetailLines = 2) + selectionDetails = ::describeDemo, selectionDetailLines = 2, initialLocation = location, + openEntry = { entry -> + if (entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) { + menuEventBus.postIntent(MenuIntent.PlayDemoRequested(profileName, entry)) + true + } else false + }) } } @@ -89,8 +103,11 @@ class ContentBrowserStage( /** Detach actors before releasing their borrowed textures; reopening supplies a new snapshot. */ fun hide() { + val browser = content.actor as? ContentBrowser + profileName?.let { profile -> browser?.let { navigation.remember(profile, it.location()) } } unfocusAll() content.actor = null + notice.actor = null media.clear() media.contentPaths = emptyList() demoDescriptions.clear() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt index 0feb2b46..f7f3e33f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt @@ -16,6 +16,7 @@ import org.demoth.cake.assets.md2.Md2Asset import org.demoth.cake.assets.md2.Md2Loader import org.demoth.cake.ui.GameUiStyle import org.demoth.cake.ui.ImageThumbnails +import org.demoth.cake.ui.ContentBrowserNavigation import org.demoth.cake.ui.UiViewport import org.demoth.cake.ui.menu.MenuEventBus import org.demoth.cake.ui.menu.MenuScreen @@ -35,6 +36,7 @@ class MenuStages( private val activeProfileId: () -> String?, ) : Disposable { private val stages = linkedMapOf() + private val browserNavigation = ContentBrowserNavigation() var activeScreen: MenuScreen = MenuScreen.MAIN private set @@ -70,7 +72,7 @@ class MenuStages( val file = requireNotNull(fileResolver.resolveContent(entry.normalizedPath)) { "Missing profile sound" } // Preview sounds are owned separately from the shared asset manager. ConvertingSoundLoader(fileResolver).loadSync(assetManager, entry.normalizedPath, file, null) - }, readDemoMetadata = readDemoMetadata) + }, readDemoMetadata = readDemoMetadata, navigation = browserNavigation) } } if (activeScreen == MenuScreen.CONTENT_BROWSER) refreshContentBrowser() @@ -116,14 +118,18 @@ class MenuStages( stages.clear() } - private fun refreshContentBrowser() { + fun showContentBrowser(message: String? = null) { + if (!select(MenuScreen.CONTENT_BROWSER) || message != null) refreshContentBrowser(message) + } + + private fun refreshContentBrowser(message: String? = null) { val profileId = activeProfileId() if (profileId == null) { contentBrowserStage.show(emptyList(), "", "Select a profile to browse its content.") return } try { - contentBrowserStage.show(listContentEntries(), profileId) + contentBrowserStage.show(listContentEntries(), profileId, message) } catch (error: Exception) { Com.Warn("Could not list profile content: ${error.message}") contentBrowserStage.show(emptyList(), profileId, "Could not list profile content.") diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt index 0f48465d..b667dc3b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt @@ -64,6 +64,8 @@ class ContentBrowser( private val showSource: Boolean = false, private val selectionDetails: (VfsEntry) -> String? = { null }, private val selectionDetailLines: Int = 0, + private val openEntry: ((VfsEntry) -> Boolean)? = null, + initialLocation: ContentBrowserLocation? = null, ) : Table() { /** Preserve the standalone viewer's native engine styling and callback contract. */ constructor( @@ -121,7 +123,8 @@ class ContentBrowser( private val gridScroll = scrollPane(grid).apply { setScrollingDisabled(true, false) } private val content = Container(gridScroll).apply { fill() } private val backButton = TextButton("Back to folder", styles.button).apply { isVisible = false } - private var thumbnailWidth = 128f + private var thumbnailWidth = initialLocation?.thumbnailWidth?.coerceIn(64f, 256f) ?: 128f + private var pendingScrollY: Float? = null private val smallerButton = TextButton("-", styles.button) private val largerButton = TextButton("+", styles.button) @@ -165,8 +168,23 @@ class ContentBrowser( override fun changed(event: ChangeEvent, actor: Actor) = resizeThumbnails(32f) }) tree.selection.set(root) + initialLocation?.let { location -> + var path = location.directory + while (path !in nodes && path.isNotEmpty()) path = path.substringBeforeLast('/', "") + val node = nodes.getValue(path) + node.expandTo() + node.isExpanded = true + tree.selection.set(node) + tiles.firstOrNull { it.entry?.normalizedPath == location.selectedPath }?.let(::selectTile) + pendingScrollY = location.scrollY.coerceAtLeast(0f) + } } + fun location(): ContentBrowserLocation = ContentBrowserLocation( + tree.selectedValue?.path.orEmpty(), tiles.firstOrNull { it.isChecked }?.entry?.normalizedPath, + pendingScrollY ?: gridScroll.scrollY, thumbnailWidth, + ) + private fun resizeThumbnails(delta: Float) { thumbnailWidth = (thumbnailWidth + delta).coerceIn(64f, 256f) tiles.forEach { it.resizePreview(thumbnailWidth) } @@ -176,6 +194,10 @@ class ContentBrowser( /** Hosts forward keys here so shortcuts work even without keyboard focus on a tile. */ fun keyDown(keycode: Int): Boolean { if (content.actor !== gridScroll) return false + if (keycode == Input.Keys.ENTER && openEntry != null) { + tiles.firstOrNull { it.isChecked }?.let(::activateTile) + return true + } val delta = when (keycode) { Input.Keys.MINUS -> -32f Input.Keys.EQUALS -> 32f @@ -216,6 +238,13 @@ class ContentBrowser( override fun act(delta: Float) { super.act(delta) + // Scroll bounds are only known after the restored folder has received its first layout. + pendingScrollY?.takeIf { gridScroll.width > 0f && gridScroll.height > 0f }?.let { + gridScroll.validate() + gridScroll.scrollY = it + gridScroll.updateVisualScroll() + pendingScrollY = null + } val loader = loadThumbnail ?: return if (content.actor !== gridScroll || stage == null) return validate() @@ -246,6 +275,7 @@ class ContentBrowser( private fun showDirectory(directory: ContentDirectory) { clearAudition() previewSelection(null) + pendingScrollY = null grid.clearChildren() tiles.clear() clearThumbnails() @@ -267,12 +297,7 @@ class ContentBrowser( grid.addActor(tile) tile.addListener(object : ClickListener() { override fun clicked(event: InputEvent, x: Float, y: Float) { - tiles.forEach { it.isChecked = it === tile } - selectionInfo.setText(entry?.let { - val basic = "${it.displayPath}\n${it.size} bytes" + if (showSource) - "\nSource: ${it.layer} / ${it.source.containerPath.fileName}" else "" - selectionDetails(it)?.let { details -> "$basic\n$details" } ?: basic - } ?: "${directory!!.path}/") + selectTile(tile) if (tapCount == 1) { clearAudition() onSelect(entry)?.let { preview -> @@ -280,22 +305,37 @@ class ContentBrowser( auditionTile = tile } } - if (tapCount != 2) return - if (directory != null) { - val node = nodes.getValue(directory.path) - node.expandTo() - node.isExpanded = true - tree.selection.set(node) - } else { - val preview = previewSelection(entry) ?: return - content.actor = scrollPane(preview) - backButton.isVisible = true - updateSizeButtons() - } + if (tapCount == 2) activateTile(tile) } }) } + private fun selectTile(tile: Tile) { + tiles.forEach { it.isChecked = it === tile } + selectionInfo.setText(tile.entry?.let { + val basic = "${it.displayPath}\n${it.size} bytes" + if (showSource) + "\nSource: ${it.layer} / ${it.source.containerPath.fileName}" else "" + selectionDetails(it)?.let { details -> "$basic\n$details" } ?: basic + } ?: "${tile.directory!!.path}/") + } + + private fun activateTile(tile: Tile) { + val directory = tile.directory + if (directory != null) { + val node = nodes.getValue(directory.path) + node.expandTo() + node.isExpanded = true + tree.selection.set(node) + } else { + val entry = tile.entry ?: return + if (openEntry?.invoke(entry) == true) return + val preview = previewSelection(entry) ?: return + content.actor = scrollPane(preview) + backButton.isVisible = true + updateSizeButtons() + } + } + private fun clearAudition() { auditionTile?.let { it.picture.actor = it.placeholder } auditionTile = null diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md index c76e2f0f..97aab667 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md @@ -57,6 +57,7 @@ artwork and atlas lines; tree slots match the 8-unit game theme. - Preview content resolves within the profile; engine palette/shaders remain available. - Selection, navigation, profile changes, and failures clear preview resources without unloading shared game textures. - Back/Escape restores folder scroll, then ascends folders; only root exits the browser. +- Demo double-click/Enter requests playback through the menu bus. Value-only, profile-scoped bookmarks retain folder/selection/scroll/thumbnail size across playback cleanup and style rebuilds; returning playback errors appear above the listing. | Media | Interaction | |---|---| diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt index a7dca6d2..411ff192 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt @@ -223,6 +223,12 @@ class MenuController( refreshState(activeScreen = MenuScreen.CONTENT_BROWSER, formOverride = state.profileEditor.form) } + is MenuIntent.PlayDemoRequested -> { + if (state.activeScreen == MenuScreen.CONTENT_BROWSER && intent.profileId == backend.activeProfileId() && + intent.entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) + bus.postSignal(MenuSignal.PlayDemo(intent.profileId, intent.entry)) + } + is MenuIntent.OpenOptions -> { refreshState( activeScreen = MenuScreen.OPTIONS, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt index acbbf04b..bef020bd 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt @@ -1,6 +1,7 @@ package org.demoth.cake.ui.menu import java.nio.file.Path +import jake2.qcommon.vfs.VfsEntry sealed interface MenuIntent { data class OpenUiSample(val game: Boolean? = null) : MenuIntent @@ -29,6 +30,7 @@ sealed interface MenuIntent { data object OpenPlayerSetup : MenuIntent data object OpenOptions : MenuIntent data object OpenContentBrowser : MenuIntent + data class PlayDemoRequested(val profileId: String, val entry: VfsEntry) : MenuIntent data class OpenOptionsSection(val prefix: String) : MenuIntent data object DisconnectRequested : MenuIntent data class SelectProfile(val profileId: String) : MenuIntent diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt index b1d80112..35273cde 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt @@ -1,6 +1,7 @@ package org.demoth.cake.ui.menu import java.nio.file.Path +import jake2.qcommon.vfs.VfsEntry enum class MenuUiSoundEffect { ENTER_SUBMENU, @@ -11,6 +12,7 @@ enum class MenuUiSoundEffect { sealed interface MenuSignal { data object ShowMenu : MenuSignal data object CloseMenu : MenuSignal + data class PlayDemo(val profileId: String, val entry: VfsEntry) : MenuSignal data class OpenUiSample(val game: Boolean? = null) : MenuSignal data object CloseUiSample : MenuSignal data class CaptureUiSample(val modes: List, val directory: Path) : MenuSignal diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt index 501de06e..f61455c9 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt @@ -74,6 +74,7 @@ class MenuStagesGlTest { verifyHostMapSelection(viewport, style) verifySavedGames(viewport, style) verifySingleplayerHub(viewport, style) + verifyBrowserDemoNavigation(viewport, style) menus.rebuild(style) val main = menus.inputProcessor assertFalse(menus.select(MenuScreen.MAIN)) @@ -151,6 +152,70 @@ class MenuStagesGlTest { } } + private fun verifyBrowserDemoNavigation(viewport: UiViewport, style: GameUiStyle) { + val entries = (0..39).map { number -> + val path = "demos/nested/clip${number.toString().padStart(2, '0')}.dm2" + jake2.qcommon.vfs.VfsEntry(path, path, jake2.qcommon.vfs.VfsLayer.BASE_PACK, 0, + jake2.qcommon.vfs.VfsSource(jake2.qcommon.vfs.VfsSourceType.PACKAGE_ENTRY, + java.nio.file.Path.of("fixture.pak"), path, "pak", true, false), 100, 0) + } + val bus = MenuEventBus() + val assets = AssetManager() + var profile = "profile" + val menus = MenuStages(viewport, bus, assets, CakeFileResolver(), false, + listContentEntries = { entries }, + readDemoMetadata = { org.demoth.cake.demo.DemoMetadata(26, "baseq2", "Test", "maps/test.bsp") }, + activeProfileId = { profile }) + fun stage() = menus.inputProcessor as Stage + fun browser() = descendants(stage().root).filterIsInstance().single() + fun clickTile(name: String, count: Int) { + val tile = descendants(stage().root).filterIsInstance