Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f2bbdb3
Add bounded protocol 34 demo record reader and body decoder
demoth Oct 29, 2026
888cc74
Add client demo playback clock with bounded record dispatch
demoth Oct 29, 2026
1af6d33
Play protocol 34 demos from the Cake client console
demoth Oct 29, 2026
bd85f98
Harden demo playback timing and failure cleanup
demoth Oct 29, 2026
55f0b84
Decode missing protocol 34 expansion temporary effects
demoth Oct 29, 2026
2fda0c4
Remove installed Rogue demo dependency from engine tests
demoth Oct 29, 2026
e7179c7
Add libGDX-backed particle runtime with resource-free tests
demoth Oct 29, 2026
85aa660
Route Cake particle emission through the libGDX runtime
demoth Oct 29, 2026
4f773f4
Add bounded presentation-clock scheduling for sustained effects
demoth Oct 29, 2026
c76b6b5
Present steam sustains and heatbeam impact particles
demoth Oct 29, 2026
710132f
Add persistent beam identity and bounded segment geometry
demoth Oct 29, 2026
b472641
Render persistent cables and lightning with pooled libGDX models
demoth Oct 29, 2026
bf16383
Add camera-attached player and recorded monster heatbeam geometry
demoth Oct 29, 2026
491c6bc
Render heatbeam segments rings and monster plasma with libGDX
demoth Oct 29, 2026
5549947
Present flashlight force-wall and dense bubble-trail effects
demoth Oct 29, 2026
09caa71
Present expanding widow and nuke particle shells
demoth Oct 29, 2026
76534d1
Restore boss teleport and tracker explosion particle components
demoth Oct 29, 2026
a73e1b5
Use family-specific impact particles counts and sound probabilities
demoth Oct 29, 2026
ba6206a
Refine retained particle motion and document family coverage
demoth Oct 29, 2026
0ff8cde
Share explosion animation rules and correct particle exclusions
demoth Oct 29, 2026
688934d
Drive explosion materials and frame lights from shared animation state
demoth Oct 29, 2026
7069d82
Render bounded BFG laser pulses through shared beam geometry
demoth Oct 29, 2026
5f8d2e4
Verify all temporary entity presentation lifecycles without graphics
demoth Oct 29, 2026
f2be642
Retain effect asset references across overlapping screens
demoth Oct 29, 2026
307c66a
Document complete temporary entity coverage and verification limits
demoth Oct 29, 2026
cf4b181
Add bounded protocol 34 demo record writer
demoth Oct 29, 2026
e4aa10a
Add owned demo bootstrap and full snapshot recording gate
demoth Oct 29, 2026
e1d91f7
Expose owned recording startup state and explicit full snapshot requests
demoth Oct 29, 2026
699ca3f
Store demo recordings in profile writable directories
demoth Oct 29, 2026
165d1d8
Wire console recording into live client packet and session lifecycles
demoth Oct 29, 2026
de5dec2
Reserve channel overhead in demo startup records for Yamagi playback
demoth Oct 29, 2026
d1a9fd6
Enable classic protocol 32 demo playback
demoth Oct 29, 2026
0474f14
Add stream owned classic demo protocol decoding
demoth Oct 29, 2026
3822e6c
Enable protocol 26 demo playback with per session version context
demoth Oct 29, 2026
484db50
Tolerate absent config models in protocol 26 demo playback
demoth Oct 29, 2026
258fbc8
Read bounded demo startup metadata without loading assets
demoth Oct 29, 2026
0f15390
Expose profile recordings as content browser entries
demoth Oct 29, 2026
8adab95
Show selected demo startup metadata in the content browser
demoth Oct 29, 2026
b2f50ab
Keep content browser bookmarks independent of actors and profile changes
demoth Oct 29, 2026
1c67a7a
Launch browser demos and restore navigation after playback
demoth Oct 29, 2026
bb5f826
Use game themed artwork for horizontal scrollbars
demoth Oct 30, 2026
9d3bb8d
Support protocol 31 client demo playback
demoth Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions cake/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,59 @@ 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 recording and playback

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.
- `demomap example` — replay the saved recording.

Recordings are saved under `~/.cake/<profile-id>/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 classic protocol-26, protocol-31, 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:

- `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 browser for browser-started playback, or the console otherwise.

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.

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. 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
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.

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)
Expand Down
3 changes: 2 additions & 1 deletion cake/cake-fullgame/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
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.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
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/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)
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
private var timeAtPause = 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
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 -> {
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 motion")
next()
}
}
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()
}
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-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()
}
}
} 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)
}
}

/** 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)
}

class CakeRemoteDemoPlaybackGlTest : CakeDemoPlaybackGlTest() {
override val embedded = false
}

class CakeHostedDemoPlaybackGlTest : CakeDemoPlaybackGlTest() {
override val embedded = true
}
Loading
Loading