From 140544a71a6111914b0bd75ede1709384145b5b4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 14:03:06 +0100 Subject: [PATCH 01/57] Keep embedded Host alive and attach clients after local startup --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 119 ++++++++++++------ .../jake2/server/CakeHostApplicationTest.kt | 27 +++- .../jake2/server/CakeLocalSessionGlTest.kt | 2 +- .../jake2/server/CakeProfileSaveGlTest.kt | 17 ++- .../kotlin/jake2/server/CakeSessionPeer.kt | 3 +- .../jake2/server/EmbeddedCakeHostTest.kt | 115 ++++++++++++++--- .../src/main/kotlin/org/demoth/cake/Cake.kt | 33 ++++- .../org/demoth/cake/hosting/CakeHost.kt | 10 ++ .../main/kotlin/jake2/server/ServerHost.kt | 9 +- 9 files changed, 260 insertions(+), 75 deletions(-) diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 1e5557d3..4c60e065 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -17,7 +17,7 @@ import java.io.IOException import java.nio.file.Files import java.nio.file.Path -/** A local server session, created only after Cake has applied its client profile. */ +/** Application-owned embedded Host; games may stop while commands and profile remain active. */ class EmbeddedCakeHost( private val profilesRoot: Path = Path.of(System.getProperty("user.home"), ".cake"), ) : CakeHost { @@ -26,13 +26,41 @@ class EmbeddedCakeHost( private var starting = false private var ownerThread: Thread? = null - /** - * EngineWriteRoot is process-wide. Hosting temporarily redirects it to the active - * profile so existing server save code writes there. Retain the prior root to - * restore it on stop or failed startup, so later engine writes do not keep using - * the ended session's profile. Null means this host has no root to restore. - */ private var previousWriteRoot: Path? = null + private var activeProfile: CakeGameProfile? = null + private var startupRejection: () -> String? = { null } + private var disposed = false + + override fun initialize(startupRejection: () -> String?) { + checkThread() + check(!disposed) { "Host has been disposed" } + check(server == null) { "Host is already initialized" } + this.startupRejection = startupRejection + previousWriteRoot = EngineWriteRoot.path() + server = ServerHost { + when { + disposed -> "Host has been disposed" + activeProfile == null -> "Select a game profile before hosting." + else -> this.startupRejection() + } + } + } + + override fun activateProfile(profile: CakeGameProfile?) { + checkThread() + check(server != null && !disposed) { "Host is not initialized" } + check(state == HostState.STOPPED) { "Stop the local game before changing profile" } + startupRejection()?.let { throw IllegalStateException(it) } + val normalized = profile?.normalized() + if (normalized == activeProfile) return + if (normalized != null) { + validateProfile(normalized) + configureFilesystem(normalized) + } else { + previousWriteRoot?.let(EngineWriteRoot::setRoot) + } + activeProfile = normalized + } override val availability = HostAvailability.AVAILABLE override val state: HostState @@ -49,22 +77,23 @@ class EmbeddedCakeHost( private fun start(request: HostRequest, prepareOnly: Boolean): HostStartResult { checkThread() + if (disposed || server == null) return HostStartResult.Rejected("Host is not initialized") + startupRejection()?.let { return HostStartResult.Rejected(it) } try { validate(request, prepareOnly) } catch (error: IllegalArgumentException) { return HostStartResult.Rejected(error.message ?: "Invalid host request") } stop("Starting a new game") - starting = true try { - configureFilesystem(request) + activateProfile(request.profile) + starting = true if (!prepareOnly && !EngineVfs.exists("maps/${request.map}.bsp")) { stop("Map not found") return HostStartResult.Rejected("Map not found: ${request.map}") } val cvars = Cvar.getInstance() - val manager = ServerHost() - server = manager + val manager = checkNotNull(server) cvars.find("deathmatch").forceSet(request.mode == HostMode.DEATHMATCH) cvars.find("coop").forceSet(request.mode == HostMode.COOP) cvars.find("maxclients").forceSet(request.maxClients) @@ -73,9 +102,16 @@ class EmbeddedCakeHost( // Consumed by the new game instance's existing early-cvar transfer. cvars.setResult("skill", request.skill.toString()) if (prepareOnly) return HostStartResult.Started - if (!manager.startMap(request.map)) { - stop("Map did not start") - return HostStartResult.Rejected("Could not start map: ${request.map}") + when (val result = manager.map(request.map)) { + ServerHost.OperationResult.Completed -> Unit + is ServerHost.OperationResult.Rejected -> { + stop("Map did not start") + return HostStartResult.Rejected(result.reason) + } + is ServerHost.OperationResult.Failed -> { + stop("Map startup failed") + return HostStartResult.Rejected(result.reason) + } } if (request.mode != HostMode.SINGLEPLAYER && NET.ip_sockets[Defines.NS_SERVER] == null) { stop("Server port unavailable") @@ -107,10 +143,7 @@ class EmbeddedCakeHost( IllegalStateException("No map after newgame")) } } - if (!manager.isRunning) { - stop("Server stopped") // e.g. killserver or sv_shutdown from the console - return - } + if (!manager.isRunning) return // An idle Host retains commands and profile context. try { manager.update(elapsedMillis.coerceAtLeast(0)) } catch (error: longjmpException) { @@ -124,23 +157,29 @@ class EmbeddedCakeHost( override fun stop(reason: String) { checkThread() + starting = false + try { + server?.stop("$reason\n", false) + } finally { + // The adapter also owns disposal of the local client's queued traffic. + if (server != null) NET.clearLoopbacks() + } + } + + override fun dispose() { + checkThread() + if (disposed) return + disposed = true + starting = false val manager = server - val ownedSession = manager != null || starting server = null - starting = false try { - manager?.stop("$reason\n", false) + manager?.close() } finally { - try { - manager?.close() - } finally { - if (ownedSession) { - // This adapter also owns disposal of the local client's queued traffic. - NET.clearLoopbacks() - } - previousWriteRoot?.let(EngineWriteRoot::setRoot) - previousWriteRoot = null - } + if (manager != null) NET.clearLoopbacks() + previousWriteRoot?.let(EngineWriteRoot::setRoot) + previousWriteRoot = null + activeProfile = null } } @@ -153,8 +192,11 @@ class EmbeddedCakeHost( cvars.find("paused").forceSet(paused && singlePlayer) } - private fun configureFilesystem(request: HostRequest) { - val profile = request.profile.normalized() + private fun configureFilesystem(profile: CakeGameProfile) { + // Validate writable storage before changing process-wide content selection. + val root = profilesRoot.resolve(profile.id) + Files.createDirectories(root) + if (!Files.isWritable(root)) throw IOException("Save directory is not writable: $root") val cvars = Cvar.getInstance() val gameMod = profile.gamemod?.takeUnless { it == "baseq2" } EngineVfs.configure(VfsConfig( @@ -166,15 +208,10 @@ class EmbeddedCakeHost( .forceSet(gameMod.orEmpty()) cvars.register("gamedir", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) .forceSet(gameMod.orEmpty()) - val root = profilesRoot.resolve(profile.id) - Files.createDirectories(root) - if (!Files.isWritable(root)) throw IOException("Save directory is not writable: $root") - previousWriteRoot = EngineWriteRoot.path() EngineWriteRoot.setRoot(root) } - private fun validate(request: HostRequest, prepareOnly: Boolean) { - val profile = request.profile.normalized() + private fun validateProfile(profile: CakeGameProfile) { fun safeDirectory(value: String) = value.isNotBlank() && value != "." && !value.contains("..") && value.none { it == '/' || it == '\\' || it == ':' } require(safeDirectory(profile.id)) { "Invalid profile id" } @@ -185,6 +222,10 @@ class EmbeddedCakeHost( require(profile.gamemod == null || Files.isDirectory(Path.of(profile.basedir).resolve(profile.gamemod))) { "Profile mod directory does not exist: ${profile.gamemod}" } + } + + private fun validate(request: HostRequest, prepareOnly: Boolean) { + validateProfile(request.profile.normalized()) require(prepareOnly || (request.map.matches(Regex("[A-Za-z0-9_/-]+")) && !request.map.startsWith('/'))) { "Map must be a relative map name without an extension" } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index ca03815e..dc36f2da 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -9,8 +9,10 @@ import jake2.game.GameExportsImpl import jake2.qcommon.Globals import jake2.qcommon.MainCommon import jake2.qcommon.exec.Cbuf +import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find +import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.network.Netchan import jake2.qcommon.Defines.NS_SERVER import jake2.qcommon.network.netadr_t @@ -27,6 +29,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable import org.junit.jupiter.api.io.TempDir import java.nio.file.Path +import java.nio.file.Files /** Exercises application lifecycle and handshake boundaries without displaying a window. */ @EnabledIfEnvironmentVariable(named = "CAKE_HOST_GL_TESTS", matches = "1") @@ -57,10 +60,21 @@ class CakeHostApplicationTest { private var cake: Cake? = null private var step = 0 private var frame = 0 + private lateinit var initialHost: ServerHost override fun create() { try { cake = Cake(startup, host = host).also { it.create() } + initialHost = host.server!! + assertEquals(HostState.STOPPED, host.state) + assertNotNull(Cmd.FindCommand("map")) + // Hosted storage rejection must precede persisted profile selection. + val originalRoot = EngineWriteRoot.path() + Files.writeString(temp.resolve(".cake/blocked"), "not a directory") + val content = Path.of("src/test/resources").toAbsolutePath() + Cmd.ExecuteString("cake_profile_set blocked \"$content\"") + assertEquals("application-test", CakeGameProfileStore().readSelectedProfileId()) + assertEquals(originalRoot, EngineWriteRoot.path()) } catch (error: Throwable) { failure = error Gdx.app.exit() @@ -90,12 +104,20 @@ class CakeHostApplicationTest { Cbuf.AddText("connect 127.0.0.1:9") cake!!.render() assertEquals(HostState.STOPPED, host.state) + assertSame(initialHost, host.server) } 3 -> { // An old local handshake must not attach while targeting a UDP server. Netchan.sendConnectionlessPacket(NS_SERVER, netadr_t(), ConnectionlessCommand.client_connect, "") cake!!.render() assertEquals("CONNECTING", clientState(cake!!)) + val current = temp.resolve(".cake/application-test/save/current/game.ssv.json") + val before = Files.readAllBytes(current) + Cbuf.AddText("map testbox2; gamemap *testbox2; load save0") + cake!!.render() + assertEquals("CONNECTING", clientState(cake!!)) + assertEquals(HostState.STOPPED, host.state) + assertArrayEquals(before, Files.readAllBytes(current)) Cbuf.AddText("host_start missing") cake!!.render() assertEquals(HostState.STOPPED, host.state) @@ -108,6 +130,7 @@ class CakeHostApplicationTest { Cbuf.AddText("host_start testbox") cake!!.render() assertEquals(HostState.RUNNING, host.state) + assertSame(initialHost, host.server) frame = gameFrame(host) } 5 -> { @@ -135,8 +158,10 @@ class CakeHostApplicationTest { }) failure?.let { throw it } assertEquals(HostState.STOPPED, host.state, "Application disposal stops the host") + assertNull(host.server) + assertNull(Cmd.FindCommand("map")) } finally { - host.stop("test cleanup") + host.dispose() Globals.fixedtime.forceSet(0) System.setProperty("user.home", previousHome) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index 906e65cf..3b9a25d2 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -195,7 +195,7 @@ class CakeLocalSessionGlTest { peer.destroy() if (!peer.waitFor(5, TimeUnit.SECONDS)) peer.destroyForcibly() } - host.stop("test cleanup") + host.dispose() System.setProperty("user.home", previousHome) } } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt index 0dc259af..c07828a1 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt @@ -46,7 +46,7 @@ class CakeProfileSaveGlTest { val source = CakeFileResolver(basedir = installation.toString()) Files.write(mod.resolve("profilemap.bsp"), source.resolve("maps/base1.bsp")!!.readBytes()) CakeGameProfileStore().upsertProfile(CakeGameProfile("first", content.toString(), "custom")) - val startup = CakeStartupBootstrap.bootstrap() + val startup = CakeStartupBootstrap.bootstrap(arguments = listOf("+map", "profilemap")) Lwjgl3Application(object : ApplicationAdapter() { private lateinit var cake: Cake private var step = 0 @@ -60,7 +60,6 @@ class CakeProfileSaveGlTest { try { cake = Cake(startup, host = host) cake.create() - Cbuf.AddText("host_start profilemap") } catch (error: Throwable) { fail(error) } } @@ -107,12 +106,12 @@ class CakeProfileSaveGlTest { 1 -> if (active("base2")) next("load shared") 2 -> if (active("profilemap")) { assertEquals(73, player().health) - next("host_stop") + next("disconnect") } 3 -> { assertEquals("DISCONNECTED", state()) Cmd.ExecuteString("cake_profile_set second \"$content\"") - next("host_start base2") + next("gamemap *base2") } 4 -> if (active("base2")) { player().health = 41 @@ -124,14 +123,14 @@ class CakeProfileSaveGlTest { 5 -> if (active("base1")) next("load shared") 6 -> if (active("base2")) { assertEquals(41, player().health) - next("host_stop") + next("disconnect") } 7 -> { Cmd.ExecuteString("cake_profile_set first \"$content\" custom") - next("host_start base2") + // Idle load must attach directly, without starting a disposable map. + next("load shared") } - 8 -> if (active("base2")) next("load shared") - 9 -> if (active("profilemap")) { + 8 -> if (active("profilemap")) { assertEquals(73, player().health) assertEquals(temp.resolve(".cake/first"), EngineWriteRoot.path()) Gdx.app.exit() @@ -154,7 +153,7 @@ class CakeProfileSaveGlTest { }) failure?.let { throw it } } finally { - host.stop("test cleanup") + host.dispose() System.setProperty("user.home", previousHome) } } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt index e96ecdb9..6876897a 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt @@ -35,6 +35,7 @@ object CakeSessionPeer { Cvar.getInstance().find("port").forceSet(0) val host = EmbeddedCakeHost() try { + host.initialize() check(host.start(HostRequest(profile, "base1", HostMode.DEATHMATCH)) == HostStartResult.Started) Files.writeString(ready, NET.ip_sockets[Defines.NS_SERVER]!!.localPort.toString()) var previous = System.nanoTime() @@ -44,7 +45,7 @@ object CakeSessionPeer { host.update((now - previous) / 1_000_000) previous = now } - } finally { host.stop("peer exit") } + } finally { host.dispose() } } else { CakeGameProfileStore().upsertProfile(profile) val startup = CakeStartupBootstrap.bootstrap() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 77f76ee4..3607ed8c 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -44,16 +44,96 @@ class EmbeddedCakeHostTest { cvars.find("clientport").forceSet(0) previousRoot = EngineWriteRoot.path() host = EmbeddedCakeHost(temp) + host.initialize() } @AfterEach fun cleanup() { - host.stop("test cleanup") + host.dispose() NET.Config(false) EngineWriteRoot.setRoot(previousRoot) cvars.clear() } + @Test + fun `idle embedded Host leaves player chat to the client`() { + val previous = Cmd.FindCommand("cmd") + var forwarded: List? = null + Cmd.AddCommand("cmd", true) { forwarded = it.toList() } + try { + Cmd.ExecuteString("say hello remote player") + assertEquals(listOf("cmd", "say", "hello", "remote", "player"), forwarded) + } finally { + Cmd.RemoveCommand("cmd") + if (previous != null) Cmd.AddCommand("cmd", previous.description, previous.function) + } + } + + @Test + fun `idle commands reject without a profile before accessing save storage`() { + val manager = host.server!! + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, manager.map("testbox")) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, manager.changeMap("*testbox")) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, manager.loadGame("missing")) + for (command in listOf("map testbox", "gamemap *testbox", "load missing")) { + Cmd.ExecuteString(command) + } + assertEquals(HostState.STOPPED, host.state) + assertEquals(previousRoot, EngineWriteRoot.path()) + assertFalse(Files.exists(temp.resolve("session"))) + assertTrue(NET.ip_sockets.all { it == null }) + } + + @Test + fun `profile activation and game restarts retain one Host until disposal`() { + val manager = host.server!! + val mapCommand = Cmd.FindCommand("map") + host.activateProfile(profile) + repeat(2) { + Cmd.ExecuteString("map testbox") + assertEquals(HostState.RUNNING, host.state) + Cmd.ExecuteString("killserver") + host.update(0) + assertEquals(HostState.STOPPED, host.state) + assertSame(manager, host.server) + assertSame(mapCommand, Cmd.FindCommand("map")) + assertEquals(temp.resolve("session"), EngineWriteRoot.path()) + } + // Loading from idle must also work on the existing manager. + assertEquals(ServerHost.OperationResult.Completed, manager.loadGame("save0")) + host.stop("done") + host.dispose() + host.dispose() + assertNull(host.server) + assertNull(Cmd.FindCommand("map")) + assertNull(Cmd.FindCommand("load")) + assertNull(Cmd.FindCommand("save")) + assertEquals(previousRoot, EngineWriteRoot.path()) + assertTrue(NET.ip_sockets.all { it == null }) + assertThrows(IllegalStateException::class.java) { host.initialize() } + } + + @Test + fun `remote connection policy guards console and typed startup without changing files`() { + host.dispose() + host = EmbeddedCakeHost(temp) + var remote = false + host.initialize { if (remote) "Disconnect from remote first" else null } + host.activateProfile(profile) + remote = true + val manager = host.server!! + for (result in listOf(manager.map("testbox"), manager.changeMap("*testbox"), manager.loadGame("missing"))) { + assertEquals(ServerHost.OperationResult.Rejected("Disconnect from remote first"), result) + } + for (command in listOf("map testbox", "gamemap *testbox", "load missing")) Cmd.ExecuteString(command) + assertInstanceOf(HostStartResult.Rejected::class.java, host.start(request())) + assertFalse(Files.exists(temp.resolve("session/save"))) + assertSame(manager, host.server) + assertEquals(HostState.STOPPED, host.state) + remote = false + assertEquals(ServerHost.OperationResult.Completed, manager.map("testbox")) + } + @Test fun `pause checks the hosted Game mode rather than startup defaults`() { assertEquals(HostStartResult.Started, host.start(request())) @@ -117,7 +197,7 @@ class EmbeddedCakeHostTest { assertInstanceOf(HostStartResult.Rejected::class.java, result) assertEquals(HostState.STOPPED, host.state) assertEquals(previousRoot, EngineWriteRoot.path()) - assertNull(Cmd.FindCommand("map")) + assertNotNull(Cmd.FindCommand("map")) assertEquals(HostStartResult.Started, host.start(request())) } @@ -149,7 +229,7 @@ class EmbeddedCakeHostTest { } @Test - fun `start uses profile files and saves and stop unregisters the session`() { + fun `start uses profile files and saves and stop retains the Host`() { assertEquals(HostState.STOPPED, host.state) assertEquals(HostStartResult.Started, host.start(request(skill = 2))) assertEquals(HostState.RUNNING, host.state) @@ -165,10 +245,10 @@ class EmbeddedCakeHostTest { host.stop("done") host.stop("again") assertEquals(HostState.STOPPED, host.state) - assertNull(host.server) - assertNull(Cmd.FindCommand("map")) - assertNull(Cmd.FindCommand("save")) - assertEquals(previousRoot, EngineWriteRoot.path()) + assertNotNull(host.server) + assertNotNull(Cmd.FindCommand("map")) + assertNotNull(Cmd.FindCommand("save")) + assertEquals(temp.resolve("session"), EngineWriteRoot.path()) assertEquals(HostStartResult.Started, host.start(request(skill = 3))) assertEquals(3, game().gameCvars.skill.typedValue) } @@ -193,13 +273,16 @@ class EmbeddedCakeHostTest { } @Test - fun `shutdown restores the command that preceded a session across map changes`() { + fun `disposal restores the command that preceded the Host across map changes`() { + host.dispose() var called = false Cmd.AddCommand("status") { called = true } + host = EmbeddedCakeHost(temp) + host.initialize() try { assertEquals(HostStartResult.Started, host.start(request())) Cmd.ExecuteString("gamemap testbox2") - host.stop("done") + host.dispose() Cmd.ExecuteString("status") assertTrue(called) } finally { @@ -242,7 +325,7 @@ class EmbeddedCakeHostTest { assertEquals(HostState.STOPPED, host.state) assertTrue(NET.ip_sockets.all { it == null }) assertTrue(NET.ip_channels.all { it == null }) - assertNull(Cmd.FindCommand("map")) + assertNotNull(Cmd.FindCommand("map")) } assertEquals(HostStartResult.Started, host.start(request(HostMode.COOP))) assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) @@ -253,12 +336,12 @@ class EmbeddedCakeHostTest { fun `missing map and bad save directory reject cleanly and allow retry`() { assertInstanceOf(HostStartResult.Rejected::class.java, host.start(request().copy(map = "missing"))) assertEquals(HostState.STOPPED, host.state) - assertNull(Cmd.FindCommand("map")) + assertNotNull(Cmd.FindCommand("map")) Files.writeString(temp.resolve("blocked"), "not a directory") assertInstanceOf(HostStartResult.Rejected::class.java, host.start(request().copy(profile = profile.copy(id = "blocked")))) assertEquals(HostState.STOPPED, host.state) - assertEquals(previousRoot, EngineWriteRoot.path()) + assertEquals(temp.resolve("session"), EngineWriteRoot.path()) assertEquals(HostStartResult.Started, host.start(request())) } @@ -281,8 +364,8 @@ class EmbeddedCakeHostTest { Cbuf.Execute() assertFalse(ran) assertEquals(HostState.STOPPED, host.state) - assertNull(Cmd.FindCommand("killserver")) - assertNull(Cmd.FindCommand("map")) + assertNotNull(Cmd.FindCommand("killserver")) + assertNotNull(Cmd.FindCommand("map")) } finally { Cmd.RemoveCommand("host_test_deferred") } @@ -294,11 +377,11 @@ class EmbeddedCakeHostTest { assertThrows(longjmpException::class.java) { Com.Error(Defines.ERR_DROP, "test drop") } host.update(0) assertEquals(HostState.STOPPED, host.state) - assertNull(Cmd.FindCommand("save")) + assertNotNull(Cmd.FindCommand("save")) assertEquals(HostStartResult.Started, host.start(request())) var called = false Cmd.AddCommand("status", true) { called = true } - host.stop("done") + host.dispose() try { Cmd.ExecuteString("status") assertTrue(called) 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 d4394d27..129cfccc 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -206,6 +206,11 @@ class Cake( override fun create() { initializeShaderCompatibility() + host.initialize { + if (networkState != DISCONNECTED && !localConnection) + "Disconnect from the remote server before starting a local game." + else null + } configureProfileContent(startup.activeProfile) reloadProfileBackground() @@ -684,7 +689,9 @@ class Cake( host.setPaused(menuVisible || consoleVisible || uiSample.isOpen || applicationPaused) host.update(hostClock.advance(Gdx.graphics.rawDeltaTime, Globals.fixedtime.typedValue, Globals.timescale.typedValue)) - if (awaitingNewGame && host.state == HostState.RUNNING) { + // 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(netadr_t(), local = true) menuVisible = false @@ -939,7 +946,11 @@ class Cake( override fun dispose() { closeUiSample() uiSample.dispose() - disconnect() + try { + disconnect() + } finally { + host.dispose() + } profilePersistence.saveConfig() menuStages.dispose() consoleStage.dispose() @@ -1483,12 +1494,21 @@ class Cake( private fun autodetectSteamBasedir(): String? = CakeStartupBootstrap.autodetectSteamBasedir() - /** Switching is permitted only while disconnected with the local host stopped. - * Persist selection before changing runtime content so rejected saves leave it untouched. - */ + /** Validate hosted storage before persisting selection; roll back the Host if persistence fails. */ private fun activateGameProfile(profile: CakeGameProfile, persistSelection: () -> String): String { profilePersistence.saveConfig() - val path = persistSelection() + val previousProfile = activeGameProfile + host.activateProfile(profile) + val path = try { + persistSelection() + } catch (failure: Exception) { + try { + host.activateProfile(previousProfile) + } catch (rollbackFailure: Exception) { + failure.addSuppressed(rollbackFailure) + } + throw failure + } applyGameProfile(profile) profilePersistence.loadConfig() return path @@ -1506,6 +1526,7 @@ class Cake( private fun configureProfileContent(profile: CakeGameProfile?) { val normalized = profile?.normalized() + host.activateProfile(normalized) activeGameProfile = normalized fileResolver.basedir = normalized?.basedir fileResolver.gamemod = normalized?.gamemod diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 70683e7f..2105a292 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -7,6 +7,13 @@ interface CakeHost { val availability: HostAvailability val state: HostState + /** Called after engine bootstrap, before application startup commands. */ + fun initialize(startupRejection: () -> String? = { null }) + /** Select content and save storage while disconnected and idle. */ + fun activateProfile(profile: CakeGameProfile?) + /** Release the application-owned Host and its command registrations. */ + fun dispose() + /** Success means the server is ready for local attachment, not that the client has spawned. */ fun start(request: HostRequest): HostStartResult /** Prepare commands/filesystem before the caller queues the content's newgame alias. */ @@ -45,6 +52,9 @@ class HostFailureException(message: String, cause: Throwable) : RuntimeException class UnavailableCakeHost : CakeHost { override val availability = HostAvailability.REMOTE_ONLY override val state = HostState.STOPPED + override fun initialize(startupRejection: () -> String?) = Unit + override fun activateProfile(profile: CakeGameProfile?) = Unit + override fun dispose() = Unit override fun start(request: HostRequest) = HostStartResult.Rejected("Hosting requires the Cake fullgame application.") override fun update(elapsedMillis: Long) = Unit diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 7d3677b7..f0270d7a 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -39,7 +39,9 @@ import jake2.server.save.ServerSaveFiles import jake2.server.save.ServerSaveJsonStore /** Owns client connections, Game routing, protocol and commands for one server Host. */ -class ServerHost : AutoCloseable { +class ServerHost @JvmOverloads constructor( + private val startupRejection: () -> String? = { null }, +) : AutoCloseable { // region Host state and command dispatch private val commands = ServerCommands() @@ -239,7 +241,8 @@ class ServerHost : AutoCloseable { commands.add("status", true) { printStatus() } commands.add("serverinfo", true) { printServerInfo() } commands.add("dumpuser", true, Command(::dumpUser)) - commands.add("say", true, Command(::say)) + // Embedded applications send player chat through their client, including remote games. + if (Globals.dedicated.typedValue) commands.add("say", true, Command(::say)) commands.add("save", true, Command(::saveGame)) commands.add("killserver") { @@ -481,6 +484,7 @@ class ServerHost : AutoCloseable { /** Preflight preserves the live session; failures after copying begins leave an idle Host. */ fun loadGame(slot: String): OperationResult { + startupRejection()?.let { return OperationResult.Rejected(it) } val saveGame = try { ServerSaveJsonStore.normalizeSlot(slot) } catch (_: IllegalArgumentException) { @@ -563,6 +567,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) fun map(level: String): OperationResult = transitionMap(level, 0, newSession = true) private fun transitionMap(level: String, clientIndex: Int, newSession: Boolean): OperationResult { + startupRejection()?.let { return OperationResult.Rejected(it) } val target = try { val parsed = prepareMapTarget(level) require(clientIndex in 0..clients.size) { "Invalid client index: $clientIndex" } From e5a5d5c93b69fcf53a6b8ab12701e8d9ed08788b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 14:20:43 +0100 Subject: [PATCH 02/57] Replace legacy hosting commands with standard server commands --- cake/README.md | 14 +++++---- .../jake2/server/CakeHostApplicationTest.kt | 19 +++++++++--- .../jake2/server/CakeLocalSessionGlTest.kt | 10 +++---- .../src/main/kotlin/org/demoth/cake/Cake.kt | 23 ++------------ .../org/demoth/cake/CakeCommandParsing.kt | 22 -------------- .../org/demoth/cake/HostCommandParsingTest.kt | 30 ------------------- 6 files changed, 31 insertions(+), 87 deletions(-) delete mode 100644 cake/core/src/test/kotlin/org/demoth/cake/HostCommandParsingTest.kt diff --git a/cake/README.md b/cake/README.md index dd0d3222..ea7fa3b2 100644 --- a/cake/README.md +++ b/cake/README.md @@ -117,12 +117,16 @@ profiles or starting another session. Console equivalents: -- `host_start [singleplayer|deathmatch|coop] [skill] [maxclients]` -- `host_stop` — disconnect and stop hosting. -- `map ` / `gamemap ` — change maps while hosting. +- `map ` / `gamemap ` — start a local game or change maps while hosting. +- `disconnect` — disconnect and stop the local game, retaining the idle Host. +- `killserver` — stop the hosted game, retaining the idle Host. -Saves are stored in `~/.cake//save/`. Console `save ` and -`load ` currently require an active game. +For multiplayer, set `coop`, `deathmatch`, and `maxclients` before starting the +map, for example `set coop 1; set deathmatch 0; set maxclients 4; map base1`. +The Host Game menu configures these settings for you. + +Saves are stored in `~/.cake//save/`. Console `load ` restores a +game even while disconnected. `save ` requires an active game. ## Subsystem Guides diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index dc36f2da..1918b2d7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -2,6 +2,7 @@ package jake2.server import com.badlogic.gdx.ApplicationAdapter import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input import com.badlogic.gdx.Graphics import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration @@ -18,6 +19,8 @@ import jake2.qcommon.Defines.NS_SERVER import jake2.qcommon.network.netadr_t import jake2.qcommon.network.messages.ConnectionlessCommand import org.demoth.cake.Cake +import org.demoth.cake.ui.menu.MenuBackend +import org.demoth.cake.ui.menu.HostGameFormState import org.demoth.cake.CakeCvars import org.demoth.cake.CakeStartupBootstrap import org.demoth.cake.fullgame.EmbeddedCakeHost @@ -68,6 +71,8 @@ class CakeHostApplicationTest { initialHost = host.server!! assertEquals(HostState.STOPPED, host.state) assertNotNull(Cmd.FindCommand("map")) + assertNull(Cmd.FindCommand("host_start")) + assertNull(Cmd.FindCommand("host_stop")) // Hosted storage rejection must precede persisted profile selection. val originalRoot = EngineWriteRoot.path() Files.writeString(temp.resolve(".cake/blocked"), "not a directory") @@ -86,7 +91,12 @@ class CakeHostApplicationTest { try { when (step++) { 0 -> { - Cbuf.AddText("host_start testbox coop") + val backend = Cake::class.java.getDeclaredMethod("createMenuBackend").apply { + isAccessible = true + }.invoke(cake) as MenuBackend + assertEquals("Starting game...", backend.startHosting(false, + HostGameFormState(map = "testbox", coop = true, maxClients = "4"))) + assertEquals("DISCONNECTED", clientState(cake!!)) cake!!.render() assertEquals(HostState.RUNNING, host.state) frame = gameFrame(host) @@ -118,7 +128,7 @@ class CakeHostApplicationTest { assertEquals("CONNECTING", clientState(cake!!)) assertEquals(HostState.STOPPED, host.state) assertArrayEquals(before, Files.readAllBytes(current)) - Cbuf.AddText("host_start missing") + Cbuf.AddText("disconnect; map missing") cake!!.render() assertEquals(HostState.STOPPED, host.state) } @@ -127,15 +137,16 @@ class CakeHostApplicationTest { Netchan.sendConnectionlessPacket(NS_SERVER, netadr_t(), ConnectionlessCommand.client_connect, "") cake!!.render() assertEquals("DISCONNECTED", clientState(cake!!)) - Cbuf.AddText("host_start testbox") + Cbuf.AddText("set coop 0; set deathmatch 0; set maxclients 1; map testbox") cake!!.render() assertEquals(HostState.RUNNING, host.state) assertSame(initialHost, host.server) frame = gameFrame(host) } 5 -> { + cake!!.keyUp(Input.Keys.GRAVE) cake!!.render() - assertEquals(frame, gameFrame(host), "The main menu pauses singleplayer") + assertEquals(frame, gameFrame(host), "The console pauses singleplayer") Gdx.app.exit() } } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index 3b9a25d2..629e0368 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -59,7 +59,7 @@ class CakeLocalSessionGlTest { try { cake = Cake(startup, host = host) cake.create() - Cbuf.AddText("host_start base1") + Cbuf.AddText("map base1") } catch (error: Throwable) { fail(error) } } @@ -104,12 +104,12 @@ class CakeLocalSessionGlTest { next("reconnect") } 4 -> if (ticks > 2 && state(cake) == "ACTIVE") { - next("host_stop") + next("disconnect") } 5 -> { assertEquals(HostState.STOPPED, host.state) assertEquals("DISCONNECTED", state(cake)) - next("host_start base1") + next("map base1") } 6 -> if (state(cake) == "ACTIVE") next("killserver") 7 -> { @@ -117,7 +117,7 @@ class CakeLocalSessionGlTest { assertEquals("DISCONNECTED", state(cake)) assertTrue(NET.ip_channels.all { it == null }) Cvar.getInstance().find("port").forceSet(0) - next("host_start base1 coop") + next("set coop 1; set deathmatch 0; set maxclients 4; map base1") } 8 -> if (state(cake) == "ACTIVE") { serverSocket = NET.ip_sockets[Defines.NS_SERVER]!! @@ -133,7 +133,7 @@ class CakeLocalSessionGlTest { 10 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2" && spawnedClients(host) == 2) { assertSame(serverSocket, NET.ip_sockets[Defines.NS_SERVER]) clientPeer.destroy() - next("host_start base1 deathmatch") + next("disconnect; set coop 0; set deathmatch 1; set maxclients 8; map base1") } 11 -> if (state(cake) == "ACTIVE") { Files.deleteIfExists(clientReady) 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 129cfccc..2de87414 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -326,7 +326,7 @@ class Cake( } if (target.type == NetAddrType.NA_LOOPBACK) { if (host.state != HostState.RUNNING) { - Com.Warn("No local server is running. Use host_start or connect to a UDP address.\n") + Com.Warn("No local server is running. Use map to start a local game or connect to a UDP address.\n") return@AddCommand } resetClientConnection() @@ -382,24 +382,6 @@ class Cake( Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { disconnect() } - Cmd.AddCommand("host_start", "Start a local server: host_start [singleplayer|deathmatch|coop] [skill] [maxclients]") { args -> - val profile = activeGameProfile - if (profile == null) { - Com.Warn("Select a game profile before hosting.\n") - return@AddCommand - } - val request = parseHostRequest(args, profile) - if (request == null) { - Com.Printf("usage: host_start [singleplayer|deathmatch|coop] [skill 0..3] [maxclients]\n") - return@AddCommand - } - if (host.availability != HostAvailability.AVAILABLE) { - Com.Warn("Hosting is unavailable in this application.\n") - return@AddCommand - } - startHostedGame(request) - } - Cmd.AddCommand("host_stop", "Stop the local game and disconnect") { disconnect() } Cmd.AddCommand("loading", "(internal) Prepare local map-change presentation") { if (host.state != HostState.STOPPED && game3dScreen != null) { beginMapTransitionRetainingConfigAssets() @@ -489,14 +471,13 @@ class Cake( Cbuf.AddLateCommands(startup.startupCommands) } - /** Shared entrypoint for the console and Host Game menu. */ + /** Menu startup uses the same automatic local attachment as console commands. */ private fun startHostedGame(request: HostRequest): HostStartResult { disconnect() val result = host.start(request) when (result) { HostStartResult.Started -> { Com.Printf("Local server started: ${request.map}\n") - beginConnection(netadr_t(), local = true) } is HostStartResult.Rejected -> Com.Warn("Could not start local server: ${result.reason}\n") } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/CakeCommandParsing.kt b/cake/core/src/main/kotlin/org/demoth/cake/CakeCommandParsing.kt index bd1d7ce8..b2f6e195 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/CakeCommandParsing.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/CakeCommandParsing.kt @@ -1,9 +1,5 @@ package org.demoth.cake -import org.demoth.cake.hosting.HostMode -import org.demoth.cake.hosting.HostRequest -import org.demoth.cake.profile.CakeGameProfile - // Cake accepts `connect` both from local UI/menu flow and from the shared command buffer. // Validate/sanitize the target up front so malformed input prints usage instead of throwing // when the command arrives with missing or blank arguments. @@ -13,21 +9,3 @@ internal fun sanitizeConnectTarget(args: List): String? { } return args[1].trim().takeIf { it.isNotEmpty() } } - -internal fun parseHostRequest(args: List, profile: CakeGameProfile): HostRequest? { - if (args.size !in 2..5) return null - val mode = when (args.getOrNull(2)?.lowercase() ?: "singleplayer") { - "singleplayer" -> HostMode.SINGLEPLAYER - "deathmatch" -> HostMode.DEATHMATCH - "coop" -> HostMode.COOP - else -> return null - } - val skill = if (args.size >= 4) args[3].toIntOrNull()?.takeIf { it in 0..3 } ?: return null else 1 - val defaultClients = when (mode) { - HostMode.SINGLEPLAYER -> 1 - HostMode.DEATHMATCH -> 8 - HostMode.COOP -> 4 - } - val maxClients = if (args.size == 5) args[4].toIntOrNull()?.takeIf { it in 1..256 } ?: return null else defaultClients - return HostRequest(profile, args[1], mode, skill, maxClients) -} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/HostCommandParsingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/HostCommandParsingTest.kt deleted file mode 100644 index ec55b669..00000000 --- a/cake/core/src/test/kotlin/org/demoth/cake/HostCommandParsingTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.demoth.cake - -import org.demoth.cake.hosting.HostMode -import org.demoth.cake.profile.CakeGameProfile -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class HostCommandParsingTest { - private val profile = CakeGameProfile("test", "/game") - - @Test - fun `console defaults and explicit options produce typed requests`() { - assertEquals(HostMode.SINGLEPLAYER, parseHostRequest(listOf("host_start", "base1"), profile)?.mode) - assertEquals(8, parseHostRequest(listOf("host_start", "q2dm1", "deathmatch"), profile)?.maxClients) - val coop = parseHostRequest(listOf("host_start", "base1", "coop", "2", "3"), profile)!! - assertEquals(profile, coop.profile) - assertEquals(2, coop.skill) - assertEquals(3, coop.maxClients) - } - - @Test - fun `invalid console arguments do not silently start a different mode`() { - for (args in listOf(listOf("host_start"), listOf("host_start", "base1", "wrong"), - listOf("host_start", "base1", "coop", "hard"), - listOf("host_start", "base1", "coop", "4"), - listOf("host_start", "base1", "coop", "1", "0"))) { - assertNull(parseHostRequest(args, profile)) - } - } -} From d215f90c50d2ce5274f403b58d22d7920173585e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 14:39:00 +0100 Subject: [PATCH 03/57] Reset gameplay startup inputs when switching Cake profiles --- cake/README.md | 5 +++++ .../jake2/server/CakeHostApplicationTest.kt | 4 ++-- .../jake2/server/CakeProfileSaveGlTest.kt | 20 ++++++++++++++++- .../src/main/kotlin/org/demoth/cake/Cake.kt | 3 +++ .../cake/profile/CakeHostStartupSettings.kt | 22 +++++++++++++++++++ .../profile/CakeProfilePersistenceTest.kt | 20 +++++++++++++++++ .../main/java/jake2/qcommon/exec/Cvar.java | 7 ++++++ 7 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/profile/CakeHostStartupSettings.kt diff --git a/cake/README.md b/cake/README.md index ea7fa3b2..6f8b01c9 100644 --- a/cake/README.md +++ b/cake/README.md @@ -115,6 +115,11 @@ and Host Game menus, using the selected profile's game data and mod. Hosting automatically connects the local player. Disconnect before switching profiles or starting another session. +Switching profiles resets built-in gameplay startup settings before loading the +selected profile's config. Video and client preferences are not reset. Initial +startup preserves command-line overrides; loading a save restores its saved +latched settings, including difficulty. + Console equivalents: - `map ` / `gamemap ` — start a local game or change maps while hosting. diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index 1918b2d7..9e1ffb2d 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -71,15 +71,15 @@ class CakeHostApplicationTest { initialHost = host.server!! assertEquals(HostState.STOPPED, host.state) assertNotNull(Cmd.FindCommand("map")) - assertNull(Cmd.FindCommand("host_start")) - assertNull(Cmd.FindCommand("host_stop")) // Hosted storage rejection must precede persisted profile selection. val originalRoot = EngineWriteRoot.path() Files.writeString(temp.resolve(".cake/blocked"), "not a directory") val content = Path.of("src/test/resources").toAbsolutePath() + Cvar.getInstance().find("hostname").forceSet("keep on failure") Cmd.ExecuteString("cake_profile_set blocked \"$content\"") assertEquals("application-test", CakeGameProfileStore().readSelectedProfileId()) assertEquals(originalRoot, EngineWriteRoot.path()) + assertEquals("keep on failure", Cvar.getInstance().find("hostname").typedValue) } catch (error: Throwable) { failure = error Gdx.app.exit() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt index c07828a1..1945f813 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt @@ -46,7 +46,12 @@ class CakeProfileSaveGlTest { val source = CakeFileResolver(basedir = installation.toString()) Files.write(mod.resolve("profilemap.bsp"), source.resolve("maps/base1.bsp")!!.readBytes()) CakeGameProfileStore().upsertProfile(CakeGameProfile("first", content.toString(), "custom")) - val startup = CakeStartupBootstrap.bootstrap(arguments = listOf("+map", "profilemap")) + Files.createDirectories(temp.resolve(".cake/first")) + Files.writeString(temp.resolve(".cake/first/config.cfg"), "set skill 1\n") + Files.createDirectories(temp.resolve(".cake/second")) + Files.writeString(temp.resolve(".cake/second/config.cfg"), "set skill 2\nset hostname second\n") + val startup = CakeStartupBootstrap.bootstrap(arguments = listOf( + "+set", "skill", "3", "+set", "flood_msgs", "6", "+map", "profilemap")) Lwjgl3Application(object : ApplicationAdapter() { private lateinit var cake: Cake private var step = 0 @@ -75,6 +80,7 @@ class CakeProfileSaveGlTest { private fun state() = Cake::class.java.getDeclaredField("networkState").apply { isAccessible = true }.get(cake).toString() + private fun settings() = (host.server!!.games.getValue("default").gameExports as GameExportsImpl).gameCvars private fun player() = (host.server!!.games.getValue("default").gameExports as GameExportsImpl).getEdict(1) as GameEntity override fun render() { @@ -86,6 +92,8 @@ class CakeProfileSaveGlTest { "Timed out at step $step, client=${state()}") when (step) { 0 -> if (active("profilemap")) { + assertEquals(3, settings().skill.typedValue) + assertEquals(6, settings().flood_msgs.typedValue) val resolver = Cake::class.java.getDeclaredField("fileResolver").apply { isAccessible = true }.get(cake) as CakeFileResolver @@ -106,6 +114,9 @@ class CakeProfileSaveGlTest { 1 -> if (active("base2")) next("load shared") 2 -> if (active("profilemap")) { assertEquals(73, player().health) + // Stage changes while live; a profile switch must discard these too. + Cmd.ExecuteString("set coop 1") + Cmd.ExecuteString("set flood_msgs 7") next("disconnect") } 3 -> { @@ -114,6 +125,10 @@ class CakeProfileSaveGlTest { next("gamemap *base2") } 4 -> if (active("base2")) { + assertFalse(settings().coop.typedValue) + assertEquals(2, settings().skill.typedValue) + assertEquals(4, settings().flood_msgs.typedValue) + assertEquals("second", Cvar.getInstance().find("hostname").typedValue) player().health = 41 Cmd.ExecuteString("save shared") assertTrue(Files.exists(secondSlot)) @@ -131,6 +146,9 @@ class CakeProfileSaveGlTest { next("load shared") } 8 -> if (active("profilemap")) { + // Saves restore latched settings; non-saved runtime options use defaults. + assertEquals(3, settings().skill.typedValue) + assertEquals(4, settings().flood_msgs.typedValue) assertEquals(73, player().health) assertEquals(temp.resolve(".cake/first"), EngineWriteRoot.path()) Gdx.app.exit() 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 2de87414..e4b92c8a 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -43,6 +43,7 @@ import org.demoth.cake.assets.* import org.demoth.cake.download.CakeDownloadSession import org.demoth.cake.hosting.* import org.demoth.cake.input.InputManager +import org.demoth.cake.profile.CakeHostStartupSettings import org.demoth.cake.profile.CakeGameProfile import org.demoth.cake.profile.CakeGameProfileStore import org.demoth.cake.profile.CakeProfilePersistence @@ -1490,6 +1491,8 @@ class Cake( } throw failure } + // Selection and hosted storage are committed before discarding outgoing gameplay inputs. + CakeHostStartupSettings.reset() applyGameProfile(profile) profilePersistence.loadConfig() return path diff --git a/cake/core/src/main/kotlin/org/demoth/cake/profile/CakeHostStartupSettings.kt b/cake/core/src/main/kotlin/org/demoth/cake/profile/CakeHostStartupSettings.kt new file mode 100644 index 00000000..8864a051 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/profile/CakeHostStartupSettings.kt @@ -0,0 +1,22 @@ +package org.demoth.cake.profile + +import jake2.qcommon.exec.Cvar + +/** Profile-switch policy for the built-in game's startup inputs, not a registry owner. + * Initial bootstrap already applies profile config and CLI overrides, so only live switches reset. + * Keep this list aligned with ServerHost/GameCvars as gameplay options are added. Operational + * settings (ports, RCON, timing, downloads), video and client preferences are application-owned here. + */ +internal object CakeHostStartupSettings { + fun reset() { + val cvars = Cvar.getInstance() + for (name in names) cvars.resetInput(name) + } + + private val names = listOf( + "deathmatch", "coop", "maxclients", "hostname", "cheats", "dmflags", + "fraglimit", "timelimit", "sv_airaccelerate", "password", "spectator_password", + "skill", "sv_gravity", "maxspectators", "g_select_empty", "flood_msgs", + "flood_persecond", "flood_waitdelay", "maxentities", "dev_blaster", "sv_maplist", + ) +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/profile/CakeProfilePersistenceTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/profile/CakeProfilePersistenceTest.kt index c600821d..17753655 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/profile/CakeProfilePersistenceTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/profile/CakeProfilePersistenceTest.kt @@ -37,6 +37,26 @@ class CakeProfilePersistenceTest { writableFactory = { DefaultWritableFileSystem(temp.resolve(it)) }) } + @Test + fun `switch to profile without config resets gameplay but retains client preferences`() { + cvars.register("coop", jake2.qcommon.exec.CvarTypes.BOOLEAN, false, 0).forceSet(true) + cvars.setResult("sv_gravity", "123") + cvars.find("name").forceSet("player preference") + cvars.find("vid_windowed_width").forceSet(1280) + bindings.setBindingByName("F1", "say retained") + store.upsertProfile(CakeGameProfile("empty", temp.toString())) + + CakeHostStartupSettings.reset() + persistence.loadConfig() + + assertFalse(cvars.find("coop").typedValue) + // The Game's eventual declaration receives its default, not the old staged input. + assertEquals(800f, cvars.register("sv_gravity", jake2.qcommon.exec.CvarTypes.FLOAT, 800f, 0).typedValue) + assertEquals("player preference", cvars.find("name").typedValue) + assertEquals(1280, cvars.find("vid_windowed_width").typedValue) + assertTrue(bindings.listBindings().contains("F1" to "say retained")) + } + @Test fun `selected profile takes precedence and config history and paths stay isolated`() { active = CakeGameProfile("runtime", temp.toString(), "rogue") diff --git a/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java b/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java index b98a1cdd..4cdab2da 100644 --- a/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java +++ b/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java @@ -132,6 +132,13 @@ public boolean isRegistered(String name) { return lookup(name) != null; } + /** Reset selected configuration input, including pending changes or input awaiting its owner. */ + public void resetInput(String name) { + earlyValues.remove(resolveCanonicalName(name)); + cvar_t var = lookup(name); + if (var != null) var.setNative(var.getDefaultValue(), true); + } + /** Moves an unregistered input to its owning registry, retaining suggested flags for declaration validation. */ public void transferEarlyValue(String name, Cvar destination) { if (destination == this || destination.isRegistered(name)) return; From a89c604e02639a426e87e15aaa02784e62d4233e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:02:17 +0100 Subject: [PATCH 04/57] Initialize saved callbacks before loading games in a fresh process --- .../jake2/server/CakeFreshLoadGlTest.kt | 138 ++++++++++++++++++ .../main/java/jake2/game/GameExportsImpl.java | 27 +++- .../main/kotlin/jake2/game/LevelSaveJson.kt | 7 +- .../kotlin/jake2/game/LevelSaveJsonTest.kt | 14 ++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/CakeFreshLoadGlTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeFreshLoadGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeFreshLoadGlTest.kt new file mode 100644 index 00000000..28cb960e --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeFreshLoadGlTest.kt @@ -0,0 +1,138 @@ +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.game.GameEntity +import jake2.game.GameExportsImpl +import jake2.qcommon.exec.Cmd +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.vfs.EngineWriteRoot +import org.demoth.cake.Cake +import org.demoth.cake.CakeStartupBootstrap +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.HostState +import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.profile.CakeGameProfileStore +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.io.TempDir +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** Save and load run in separate JVMs; no engine state can survive between launches. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +class CakeFreshLoadGlTest { + @TempDir lateinit var temp: Path + + @Test + fun `fresh launch loads selected profile directly and preserves source slot`() { + val basedir = requireNotNull(System.getenv("CAKE_TEST_BASEDIR")) + runApplication("save", basedir) + val slot = temp.resolve(".cake/fresh/save/checkpoint") + val savedFiles = Files.walk(slot).use { paths -> + paths.filter(Files::isRegularFile).toList().associate { slot.relativize(it) to Files.readAllBytes(it) } + } + assertTrue(savedFiles.isNotEmpty()) + runApplication("load", basedir) + for ((relative, bytes) in savedFiles) assertArrayEquals(bytes, Files.readAllBytes(slot.resolve(relative)), + "Loading must preserve checkpoint/$relative") + } + + private fun runApplication(mode: String, basedir: String) { + val urls = generateSequence(javaClass.classLoader) { it.parent } + .filterIsInstance().flatMap { it.urLs.asSequence() } + .map { Path.of(it.toURI()).toString() }.toList() + val classpath = (urls + System.getProperty("java.class.path").split(java.io.File.pathSeparator)) + .distinct().joinToString(java.io.File.pathSeparator) + val log = temp.resolve("$mode.log") + val process = ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--enable-native-access=ALL-UNNAMED", "-Duser.home=$temp", "-cp", classpath, + CakeFreshLoadPeer::class.java.name, mode, basedir, + ).redirectErrorStream(true).redirectOutput(log.toFile()).start() + try { + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "Fresh $mode timed out; see $log") + assertEquals(0, process.exitValue(), "Fresh $mode failed:\n${Files.readString(log)}") + } finally { + if (process.isAlive) { + process.destroy() + if (!process.waitFor(5, TimeUnit.SECONDS)) process.destroyForcibly() + } + } + } +} + +/** Uses the fullgame composition and real bootstrap +commands, with hidden rendering for CI. */ +object CakeFreshLoadPeer { + @JvmStatic + fun main(args: Array) { + val saving = args[0] == "save" + if (saving) CakeGameProfileStore().upsertProfile(CakeGameProfile("fresh", args[1])) + // The load process discovers the persisted selection, without recreating the profile. + val startup = CakeStartupBootstrap.bootstrap(arguments = if (saving) + listOf("+set", "skill", "3", "+map", "base1") else listOf("+load", "checkpoint")) + val host = EmbeddedCakeHost() + var failure: Throwable? = null + var completed = false + val deadline = System.nanoTime() + 45_000_000_000L + try { + Lwjgl3Application(object : ApplicationAdapter() { + private lateinit var cake: Cake + override fun create() { + try { + cake = Cake(startup, host = host) + cake.create() + check(host.state == HostState.STOPPED) // Startup command has not executed yet. + check(host.server!!.games.isEmpty()) + } catch (error: Throwable) { fail(error) } + } + private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } + override fun render() { + if (failure != null || completed) return + try { + check(System.nanoTime() < deadline) { "Timed out waiting for fresh ${args[0]} attachment" } + cake.render() + val state = Cake::class.java.getDeclaredField("networkState").apply { + isAccessible = true + }.get(cake).toString() + if (state != "ACTIVE") return + check(Cvar.getInstance().find("mapname").typedValue == "base1") + check(EngineWriteRoot.path() == Path.of(System.getProperty("user.home"), ".cake/fresh")) + val game = host.server!!.games.getValue("default").gameExports as GameExportsImpl + check(game.gameCvars.skill.typedValue == 3) + val player = game.getEdict(1) as GameEntity + if (saving) { + player.health = 73 + Cmd.ExecuteString("save checkpoint") + check(Files.exists(EngineWriteRoot.path().resolve("save/checkpoint/game.ssv.json"))) + } else { + check(player.health == 73) { "Loaded player health ${player.health}, expected 73" } + } + completed = true + Gdx.app.exit() + } catch (error: Throwable) { fail(error) } + } + 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 } + check(completed) { "Application exited before verifying ${args[0]}" } + } finally { host.dispose() } + } +} diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 2ccc4fb9..9133a3ff 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -89,6 +89,28 @@ public class GameExportsImpl implements GameExports { "jake2.game.monsters.M_Supertank", "jake2.game.monsters.M_Tank", "jake2.game.items.GameItems", + // Kotlin callbacks must exist before restoring saved entity behavior. + "jake2.game.LightKt", + "jake2.game.TriggersKt", + "jake2.game.PathKt", + "jake2.game.InfoEntitiesKt", + "jake2.game.TurretKt", + "jake2.game.TargetEntitiesKt", + "jake2.game.MiscEntitiesKt", + "jake2.game.func.Func_commonKt", + "jake2.game.func.Func_objectKt", + "jake2.game.func.AreaportalKt", + "jake2.game.func.ClockKt", + "jake2.game.func.DoorKt", + "jake2.game.func.ButtonKt", + "jake2.game.func.ConveyorKt", + "jake2.game.func.RotatingKt", + "jake2.game.func.PlatKt", + "jake2.game.func.TrainKt", + "jake2.game.func.KillboxKt", + "jake2.game.func.WallKt", + "jake2.game.func.TimerKt", + "jake2.game.func.ExplosiveKt", }; ///////////////////////////////////// @@ -159,13 +181,12 @@ public GameExportsImpl(GameImports imports) { this.gameImports = imports; gameImports.dprintf("==== InitGame ====\n"); - // preload all classes to register the adapters - // fixme + // Register callbacks before reading a save, including those first used during simulation. for (String className : preloadclasslist) { try { Class.forName(className); } catch (Exception e) { - gameImports.dprintf("error loading class: " + e.getClass().getSimpleName() + " : " + e.getMessage()); + throw new IllegalStateException("Could not initialize saved-game callbacks: " + className, e); } } diff --git a/game/src/main/kotlin/jake2/game/LevelSaveJson.kt b/game/src/main/kotlin/jake2/game/LevelSaveJson.kt index 7b144016..062fcc9a 100644 --- a/game/src/main/kotlin/jake2/game/LevelSaveJson.kt +++ b/game/src/main/kotlin/jake2/game/LevelSaveJson.kt @@ -682,12 +682,13 @@ object LevelSaveSnapshots { private fun interactAdapter(id: String?): EntInteractAdapter? = resolveAdapter(id) private fun aiAdapter(id: String?): AIAdapter? = resolveAdapter(id) - @Suppress("UNCHECKED_CAST") - private fun resolveAdapter(id: String?): T? { + private inline fun resolveAdapter(id: String?): T? { if (id == null) { return null } - return SuperAdapter.getFromID(id) as? T + val adapter = SuperAdapter.getFromID(id) + require(adapter is T) { "Unknown or incompatible saved callback: $id (expected ${T::class.java.simpleName})" } + return adapter } private fun copy(source: FloatArray?, target: FloatArray, expectedLength: Int) { diff --git a/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt b/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt index 0b249c1a..5a16f50e 100644 --- a/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt +++ b/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Path @@ -22,6 +23,19 @@ class LevelSaveJsonTest { @TempDir lateinit var tempDir: Path + @Test + fun `unknown and incorrectly typed saved callbacks fail during restoration`() { + val entity = GameEntity(0) + val wrongType = SuperAdapter.registerUse("test.level.wrongThinkType") { _, _, _, _ -> } + for (id in listOf("test.level.unregisteredThink", wrongType.iD)) { + val snapshot = LevelSaveSnapshots.snapshot(0, entity).copy(thinkActionId = id) + val error = assertThrows(IllegalArgumentException::class.java) { + LevelSaveSnapshots.apply(entity, snapshot, emptyList(), arrayOf(entity), emptyArray()) + } + assertTrue(error.message!!.contains(id)) + } + } + @Test fun levelLocalsAndEntitySnapshotsRoundTripRepresentativeState() { val think = SuperAdapter.registerThink("test.level.think") { _, _ -> true } From f27ce8ee19997d0b7e2412327673afe5f7dcab34 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:09:35 +0100 Subject: [PATCH 05/57] Verify New Game alias failures, waits, and pause recovery --- .../kotlin/jake2/server/CakeNewGameGlTest.kt | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt new file mode 100644 index 00000000..1896e236 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt @@ -0,0 +1,176 @@ +package jake2.server + +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import jake2.game.GameExportsImpl +import jake2.qcommon.exec.Cmd +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import org.demoth.cake.Cake +import org.demoth.cake.CakeStartupBootstrap +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.HostState +import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.profile.CakeGameProfileStore +import org.demoth.cake.ui.menu.HostGameFormState +import org.demoth.cake.ui.menu.MenuEventBus +import org.demoth.cake.ui.menu.MenuIntent +import org.demoth.cake.ui.menu.MenuScreen +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +/** Exercises content-defined New Game scripts through the rendered menu and real client handshake. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +class CakeNewGameGlTest { + @TempDir lateinit var temp: Path + + @Test + fun `menu reports alias errors and can retry a waiting campaign script`() { + val installation = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) + val content = Files.createDirectories(temp.resolve("content")) + Files.createSymbolicLink(content.resolve("baseq2"), installation.resolve("baseq2")) + val scripts = mapOf( + "missing" to "", + "nomap" to "alias newgame \"killserver; wait\"", + "badmap" to "alias newgame \"killserver; wait; gamemap *missing-map\"", + "campaign" to "alias newgame \"killserver; wait; wait; gamemap *base1\"", + ) + for ((mod, script) in scripts) { + Files.writeString(Files.createDirectories(content.resolve(mod)).resolve("default.cfg"), script) + } + val previousHome = System.getProperty("user.home") + System.setProperty("user.home", temp.toString()) + val host = EmbeddedCakeHost() + var failure: Throwable? = null + try { + CakeGameProfileStore().upsertProfile(CakeGameProfile("missing", content.toString(), "missing")) + val startup = CakeStartupBootstrap.bootstrap() + Lwjgl3Application(object : ApplicationAdapter() { + private lateinit var cake: Cake + private lateinit var bus: MenuEventBus + private lateinit var manager: ServerHost + private var step = 0 + private var ticks = 0 + private var frame = 0 + private val deadline = System.nanoTime() + 30_000_000_000L + + override fun create() { + try { + cake = Cake(startup, host = host) + cake.create() + bus = field("menuEventBus") as MenuEventBus + manager = host.server!! + startFromMenu() + cake.render() + assertTrue(bus.latestState().singleplayer.statusMessage.contains("does not define newgame")) + assertEquals(HostState.STOPPED, host.state) + assertEquals("DISCONNECTED", state()) + + for (mod in listOf("nomap", "badmap")) { + select(mod) + startFromMenu() + cake.render() + if (mod == "badmap") assertEquals(HostState.STARTING, host.state) + assertEquals("DISCONNECTED", state(), "wait must not attach a client prematurely") + cake.render() // Alias finishes without a running map; Cake handles HostFailureException. + cake.render() // The queued HostingFailed intent reaches the menu. + assertEquals(HostState.STOPPED, host.state) + assertEquals("DISCONNECTED", state()) + assertEquals(MenuScreen.SINGLEPLAYER, bus.latestState().activeScreen) + assertTrue(bus.latestState().singleplayer.statusMessage.contains("did not start a map")) + assertEquals(true, field("menuVisible")) + assertSame(manager, host.server) + } + select("campaign") + startFromMenu() + repeat(2) { + cake.render() + assertEquals(HostState.STARTING, host.state) + assertEquals("DISCONNECTED", state()) + } + } catch (error: Throwable) { fail(error) } + } + + private fun field(name: String): Any? = Cake::class.java.getDeclaredField(name).apply { + isAccessible = true + }.get(cake) + private fun state() = field("networkState").toString() + private fun game() = manager.games.getValue("default").gameExports as GameExportsImpl + private fun select(mod: String) = Cmd.ExecuteString("cake_profile_set $mod \"$content\" $mod") + private fun startFromMenu() { + bus.postIntent(MenuIntent.OpenHostingSetup(true)) + bus.postIntent(MenuIntent.StartHosting(true, HostGameFormState(skill = 2))) + } + private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } + + override fun render() { + if (failure != null) return + try { + assertTrue(System.nanoTime() < deadline, "Timed out at step $step, client=${state()}") + cake.render() + when (step) { + 0 -> if (state() == "ACTIVE") { + assertSame(manager, host.server) + assertEquals("base1", Cvar.getInstance().find("mapname").typedValue) + assertEquals(2, game().gameCvars.skill.typedValue) + assertFalse(game().gameCvars.coop.typedValue) + assertFalse(game().gameCvars.deathmatch.typedValue) + assertEquals(1, manager.clients.size) + assertEquals(false, field("menuVisible")) + cake.keyUp(Input.Keys.ESCAPE) + frame = game().level.framenum + step++ + } + 1 -> { + assertEquals(frame, game().level.framenum, "Singleplayer menu pauses simulation") + if (++ticks == 3) { + cake.keyUp(Input.Keys.ESCAPE) + cake.pause() + ticks = 0 + step++ + } + } + 2 -> { + assertEquals(frame, game().level.framenum, "Focus loss keeps singleplayer paused") + if (++ticks == 3) { + cake.resume() + step++ + } + } + 3 -> if (game().level.framenum > frame) { + assertEquals("ACTIVE", state()) + Cmd.ExecuteString("disconnect") + assertEquals(HostState.STOPPED, host.state) + assertSame(manager, host.server) + Gdx.app.exit() + } + } + } catch (error: Throwable) { fail(error) } + } + 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) + } + } +} From 5098776c4f722fb696369dadea1138082b3b2476 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:20:33 +0100 Subject: [PATCH 06/57] Add profile save catalog and typed singleplayer save/load access --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 100 +++++++++ .../jake2/server/CakeProfileSaveGlTest.kt | 21 +- .../jake2/server/EmbeddedCakeSavesTest.kt | 197 ++++++++++++++++++ .../cake/fullgame/HostAvailabilityTest.kt | 10 + .../org/demoth/cake/hosting/CakeHost.kt | 12 ++ .../org/demoth/cake/hosting/HostSaves.kt | 29 +++ .../java/jake2/server/GameImportsImpl.java | 2 +- .../kotlin/jake2/server/ServerSaveCatalog.kt | 79 +++++++ .../jake2/server/ServerSaveCatalogTest.kt | 80 +++++++ 9 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt create mode 100644 server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt create mode 100644 server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 4c60e065..fedef86e 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -11,11 +11,17 @@ import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.vfs.VfsConfig import jake2.server.ServerHost +import jake2.server.ServerSaveCatalog +import jake2.server.SavedGameEntry +import jake2.server.SavedGameMode +import jake2.server.save.ServerSaveJsonStore import org.demoth.cake.hosting.* import org.demoth.cake.profile.CakeGameProfile import java.io.IOException import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS import java.nio.file.Path +import java.util.UUID /** Application-owned embedded Host; games may stop while commands and profile remain active. */ class EmbeddedCakeHost( @@ -192,6 +198,100 @@ class EmbeddedCakeHost( cvars.find("paused").forceSet(paused && singlePlayer) } + override fun saveAccess(): HostSaveAccess { + checkThread() + val manager = server + val game = manager?.games?.get("default") + val loadReason = saveContextRejection() ?: when { + starting -> "Wait for the game to finish starting." + manager?.isRunning == true && (manager.games.size != 1 || game == null || + game.isCoop || game.isDeathmatch || manager.clients.size != 1) -> + "Save/load menus are available only in singleplayer." + else -> null + } + val saveReason = loadReason ?: game?.saveRejectionReason() ?: if (game == null) { + "Start a singleplayer game before saving." + } else null + return HostSaveAccess(loadReason, saveReason) + } + + override fun listSaves(): HostSaveCatalogResult { + checkThread() + saveContextRejection()?.let { return HostSaveCatalogResult.Rejected(it) } + val profile = checkNotNull(activeProfile) + return try { + val entries = ServerSaveCatalog(profileRoot(profile)).list().map(::toHostSaveEntry) + HostSaveCatalogResult.Available(profile.id, entries) + } catch (failure: Exception) { + HostSaveCatalogResult.Failed(failure.message ?: "Could not read saved games.", failure) + } + } + + override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { + checkThread() + saveAccess().saveRejectionReason?.let { return HostSaveResult.Rejected(it) } + saveStorageRejection()?.let { return HostSaveResult.Rejected(it) } + val destination = normalizedSaveSlot(slot ?: "manual-${UUID.randomUUID()}") + ?: return HostSaveResult.Rejected("Invalid save slot.") + if (destination == "current" || destination == "save0") { + return HostSaveResult.Rejected("This slot is reserved for the game.") + } + val path = profileRoot(checkNotNull(activeProfile)).resolve("save").resolve(destination) + if (Files.exists(path, NOFOLLOW_LINKS)) { + if (!Files.isDirectory(path, NOFOLLOW_LINKS)) return HostSaveResult.Rejected("Invalid save directory.") + if (!overwrite) return HostSaveResult.Rejected("Confirm replacing this saved game.") + } + return checkNotNull(server).saveGame(destination).toHostSaveResult(destination) + } + + override fun loadGame(slot: String): HostSaveResult { + checkThread() + saveAccess().loadRejectionReason?.let { return HostSaveResult.Rejected(it) } + saveStorageRejection()?.let { return HostSaveResult.Rejected(it) } + val source = normalizedSaveSlot(slot) ?: return HostSaveResult.Rejected("Invalid save slot.") + if (source == "current") return HostSaveResult.Rejected("The working save is internal.") + val entry = toHostSaveEntry(ServerSaveCatalog(profileRoot(checkNotNull(activeProfile))).inspect(source)) + entry.loadRejectionReason?.let { return HostSaveResult.Rejected(it) } + return checkNotNull(server).loadGame(source).toHostSaveResult(source) + } + + private fun saveContextRejection(): String? = when { + disposed || server == null -> "Host is not initialized." + activeProfile == null -> "Select a game profile first." + else -> startupRejection() + } + + // Browsing uses the profile directly. Backend operations still use the shared write root. + private fun saveStorageRejection(): String? = if ( + EngineWriteRoot.path().toAbsolutePath().normalize() != profileRoot(checkNotNull(activeProfile)) + ) "The active save directory does not match the selected profile." else null + + private fun profileRoot(profile: CakeGameProfile): Path = + profilesRoot.resolve(profile.id).toAbsolutePath().normalize() + + private fun normalizedSaveSlot(slot: String): String? = try { + ServerSaveJsonStore.normalizeSlot(slot) + } catch (_: IllegalArgumentException) { + null + } + + private fun toHostSaveEntry(entry: SavedGameEntry): HostSaveEntry { + val mode = when (entry.mode) { + SavedGameMode.SINGLEPLAYER -> HostMode.SINGLEPLAYER + SavedGameMode.COOP -> HostMode.COOP + SavedGameMode.DEATHMATCH -> HostMode.DEATHMATCH + null -> null + } + return HostSaveEntry(entry.slot, entry.comment, entry.mapName, entry.savedAtMillis, mode, + entry.problem ?: if (mode != HostMode.SINGLEPLAYER) "This save is not a singleplayer game." else null) + } + + private fun ServerHost.OperationResult.toHostSaveResult(slot: String): HostSaveResult = when (this) { + ServerHost.OperationResult.Completed -> HostSaveResult.Completed(slot) + is ServerHost.OperationResult.Rejected -> HostSaveResult.Rejected(reason) + is ServerHost.OperationResult.Failed -> HostSaveResult.Failed(reason, cause) + } + private fun configureFilesystem(profile: CakeGameProfile) { // Validate writable storage before changing process-wide content selection. val root = profilesRoot.resolve(profile.id) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt index 1945f813..b129ee51 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt @@ -16,6 +16,8 @@ import org.demoth.cake.Cake import org.demoth.cake.CakeStartupBootstrap import org.demoth.cake.assets.CakeFileResolver import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.HostSaveCatalogResult +import org.demoth.cake.hosting.HostSaveResult import org.demoth.cake.profile.CakeGameProfile import org.demoth.cake.profile.CakeGameProfileStore import org.junit.jupiter.api.Assertions.* @@ -31,7 +33,7 @@ class CakeProfileSaveGlTest { @TempDir lateinit var temp: Path @Test - fun `mod resolution and console saves remain isolated across profile switches`() { + fun `mod resolution and console and typed saves remain isolated across profile switches`() { val installation = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) val content = Files.createDirectories(temp.resolve("content")) Files.createSymbolicLink(content.resolve("baseq2"), installation.resolve("baseq2")) @@ -75,6 +77,11 @@ class CakeProfileSaveGlTest { stageStart = System.nanoTime() Cbuf.AddText(command) } + private fun loadShared() { + next("") + assertEquals(HostSaveResult.Completed("shared"), host.loadGame("shared")) + } + private fun catalog() = assertInstanceOf(HostSaveCatalogResult.Available::class.java, host.listSaves()) private fun active(map: String) = ticks > 2 && state() == "ACTIVE" && Cvar.getInstance().find("mapname").typedValue == map private fun state() = Cake::class.java.getDeclaredField("networkState").apply { @@ -108,10 +115,11 @@ class CakeProfileSaveGlTest { assertEquals(temp.resolve(".cake/first"), EngineWriteRoot.path()) player().health = 73 Cmd.ExecuteString("save shared") + assertNull(catalog().entries.single { it.slot == "shared" }.loadRejectionReason) firstSave = Files.readString(firstSlot) next("gamemap base2") } - 1 -> if (active("base2")) next("load shared") + 1 -> if (active("base2")) loadShared() 2 -> if (active("profilemap")) { assertEquals(73, player().health) // Stage changes while live; a profile switch must discard these too. @@ -122,6 +130,8 @@ class CakeProfileSaveGlTest { 3 -> { assertEquals("DISCONNECTED", state()) Cmd.ExecuteString("cake_profile_set second \"$content\"") + assertEquals("second", catalog().profileId) + assertTrue(catalog().entries.isEmpty()) next("gamemap *base2") } 4 -> if (active("base2")) { @@ -130,12 +140,12 @@ class CakeProfileSaveGlTest { assertEquals(4, settings().flood_msgs.typedValue) assertEquals("second", Cvar.getInstance().find("hostname").typedValue) player().health = 41 - Cmd.ExecuteString("save shared") + assertEquals(HostSaveResult.Completed("shared"), host.saveGame("shared")) assertTrue(Files.exists(secondSlot)) assertEquals(firstSave, Files.readString(firstSlot)) next("gamemap base1") } - 5 -> if (active("base1")) next("load shared") + 5 -> if (active("base1")) loadShared() 6 -> if (active("base2")) { assertEquals(41, player().health) next("disconnect") @@ -143,7 +153,8 @@ class CakeProfileSaveGlTest { 7 -> { Cmd.ExecuteString("cake_profile_set first \"$content\" custom") // Idle load must attach directly, without starting a disposable map. - next("load shared") + assertEquals("first", catalog().profileId) + loadShared() } 8 -> if (active("profilemap")) { // Saves restore latched settings; non-saved runtime options use defaults. diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt new file mode 100644 index 00000000..a63de374 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -0,0 +1,197 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.Globals +import jake2.qcommon.MainCommon +import jake2.qcommon.ServerStates +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.network.NET +import jake2.qcommon.network.Netchan +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.server.save.ServerSaveFiles +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.* +import org.demoth.cake.profile.CakeGameProfile +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +class EmbeddedCakeSavesTest { + @TempDir lateinit var root: Path + private lateinit var host: EmbeddedCakeHost + private lateinit var previousRoot: Path + private val cvars = Cvar.getInstance() + private var remote = false + private val profile get() = CakeGameProfile("first", Path.of("src/test/resources").toAbsolutePath().toString()) + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + cvars.clear() + Globals.server_state = ServerStates.SS_DEAD + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + cvars.find("port").forceSet(0) + cvars.find("clientport").forceSet(0) + host = EmbeddedCakeHost(root) + host.initialize { if (remote) "Disconnect from remote first." else null } + } + + @AfterEach + fun cleanup() { + host.dispose() + NET.Config(false) + EngineWriteRoot.setRoot(previousRoot) + cvars.clear() + } + + private fun start(mode: HostMode = HostMode.SINGLEPLAYER) { + assertEquals(HostStartResult.Started, host.start(HostRequest(profile, "testbox", mode))) + } + + private fun spawn(health: Int = 73) { + val manager = host.server!! + val client = manager.clients.single() + client.state = ClientStates.CS_SPAWNED + client.edict = manager.games.getValue("default").gameExports.getEdict(1) + client.edict.getClient().playerState.stats[Defines.STAT_HEALTH] = health.toShort() + } + + private fun entries() = assertInstanceOf(HostSaveCatalogResult.Available::class.java, host.listSaves()).entries + + @Test + fun `no profile remote and disposed operations reject without accessing fallback storage`() { + assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("slot")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame()) + host.activateProfile(profile) + assertTrue(entries().isEmpty()) + assertNull(host.saveAccess().loadRejectionReason) + assertNotNull(host.saveAccess().saveRejectionReason) + remote = true + assertNotNull(host.saveAccess().loadRejectionReason) + assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame("slot", true)) + assertFalse(Files.exists(root.resolve("first/save"))) + host.dispose() + assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + } + + @Test + fun `catalog and load follow selected profile while idle and use profile rather than shared root`() { + start() + ServerSaveFiles(root.resolve("first")).copy("save0", "console slot") + host.stop("browse") + assertEquals(setOf("save0", "console slot"), entries().map { it.slot }.toSet()) + EngineWriteRoot.setRoot(root.resolve("unrelated")) + assertEquals(setOf("save0", "console slot"), entries().map { it.slot }.toSet()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("console slot")) + EngineWriteRoot.setRoot(root.resolve("first")) + host.activateProfile(profile.copy(id = "second")) + assertTrue(entries().isEmpty()) + assertEquals("second", (host.listSaves() as HostSaveCatalogResult.Available).profileId) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("console slot")) + host.activateProfile(profile) + assertEquals(HostSaveResult.Completed("console slot"), host.loadGame("console slot")) + assertEquals("testbox", host.server!!.games.getValue("default").mapName) + } + + @Test + fun `singleplayer save requires spawned living player and protects reserved slots and overwrite`() { + start() + assertNotNull(host.saveAccess().saveRejectionReason) + spawn(0) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame("dead")) + spawn() + assertNull(host.saveAccess().saveRejectionReason) + for (slot in listOf("current", "save0", "../outside")) { + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame(slot, true)) + } + val created = assertInstanceOf(HostSaveResult.Completed::class.java, host.saveGame()) + assertTrue(created.slot.startsWith("manual-")) + val file = root.resolve("first/save/${created.slot}/game.ssv.json") + val before = Files.readAllBytes(file) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame(created.slot)) + assertArrayEquals(before, Files.readAllBytes(file)) + assertEquals(HostSaveResult.Completed(created.slot), host.saveGame(created.slot, overwrite = true)) + host.stop("load idle") + assertEquals(HostSaveResult.Completed(created.slot), host.loadGame(created.slot)) + } + + @Test + fun `coop and deathmatch saves are visible but cannot be loaded through singleplayer API`() { + for (mode in listOf(HostMode.COOP, HostMode.DEATHMATCH)) { + start(mode) + val manager = host.server!! + manager.games.getValue("default").SV_WriteServerFile(true) + ServerSaveFiles(root.resolve("first")).copy("current", "multiplayer") + assertNotNull(host.saveAccess().saveRejectionReason) + assertNotNull(host.saveAccess().loadRejectionReason) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame("slot")) + host.stop("inspect") + val entry = entries().single { it.slot == "multiplayer" } + assertEquals(mode, entry.mode) + assertNotNull(entry.loadRejectionReason) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("multiplayer")) + assertEquals(HostState.STOPPED, host.state) + } + } + + @Test + fun `corrupt entries do not hide good saves and preflight rejection preserves live game`() { + start() + val manager = host.server!! + val live = manager.games.getValue("default") + val files = ServerSaveFiles(root.resolve("first")) + files.copy("save0", "corrupt") + Files.writeString(root.resolve("first/save/corrupt/server_mapcmd.ssv.json"), "bad json") + assertNotNull(entries().single { it.slot == "corrupt" }.loadRejectionReason) + assertNull(entries().single { it.slot == "save0" }.loadRejectionReason) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("corrupt")) + // Browsing reads metadata only; authoritative preflight still rejects a corrupt game envelope. + files.copy("save0", "bad-game") + Files.writeString(root.resolve("first/save/bad-game/game.ssv.json"), "bad json") + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("bad-game")) + assertSame(live, manager.games["default"]) + } + + @Test + fun `typed save failure keeps live game and late load failure leaves reusable idle Host`() { + start() + spawn() + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint")) + val manager = host.server!! + val live = manager.games.getValue("default") + val blocked = root.resolve("first/save/checkpoint/game.ssv.json") + Files.delete(blocked) + Files.createDirectory(blocked) + Files.writeString(blocked.resolve("blocker"), "do not replace") + assertInstanceOf(HostSaveResult.Failed::class.java, host.saveGame("checkpoint", true)) + assertSame(live, manager.games["default"]) + Files.delete(blocked.resolve("blocker")) + Files.delete(blocked) + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint", true)) + val current = root.resolve("first/save/current/game.ssv.json") + Files.delete(current) + Files.createDirectory(current) + Files.writeString(current.resolve("blocker"), "force late failure") + assertInstanceOf(HostSaveResult.Failed::class.java, host.loadGame("checkpoint")) + assertEquals(HostState.STOPPED, host.state) + assertSame(manager, host.server) + Files.delete(current.resolve("blocker")) + Files.delete(current) + assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) + } + + @Test + fun `catalog IO failure is distinct from an empty save list`() { + host.activateProfile(profile) + Files.writeString(root.resolve("first/save"), "not a directory") + assertInstanceOf(HostSaveCatalogResult.Failed::class.java, host.listSaves()) + } +} diff --git a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt index 37ad8927..27692c23 100644 --- a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt @@ -8,6 +8,16 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class HostAvailabilityTest { + @Test + fun `remote-only save API reports unavailable without a server`() { + val host: CakeHost = UnavailableCakeHost() + assertNotNull(host.saveAccess().loadRejectionReason) + assertNotNull(host.saveAccess().saveRejectionReason) + assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame()) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + } + @Test fun `host construction and invalid starts do not bootstrap server state`() { val cvars = Cvar.getInstance() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 2105a292..595c42ee 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -24,6 +24,13 @@ interface CakeHost { fun stop(reason: String) /** Implementations only pause singleplayer; multiplayer must keep running. */ fun setPaused(paused: Boolean) + + /** Singleplayer UI policy; operations recheck eligibility before doing any work. */ + fun saveAccess(): HostSaveAccess + fun listSaves(): HostSaveCatalogResult + /** Null creates a generated slot; replacing an existing manual slot requires overwrite. */ + fun saveGame(slot: String? = null, overwrite: Boolean = false): HostSaveResult + fun loadGame(slot: String): HostSaveResult } enum class HostAvailability { REMOTE_ONLY, NOT_READY, AVAILABLE } @@ -50,6 +57,7 @@ class HostFailureException(message: String, cause: Throwable) : RuntimeException /** Remote-only builds can accept the same application wiring without server side effects. */ class UnavailableCakeHost : CakeHost { + private val saveUnavailable = "Save/load requires Cake fullgame." override val availability = HostAvailability.REMOTE_ONLY override val state = HostState.STOPPED override fun initialize(startupRejection: () -> String?) = Unit @@ -60,4 +68,8 @@ class UnavailableCakeHost : CakeHost { override fun update(elapsedMillis: Long) = Unit override fun stop(reason: String) = Unit override fun setPaused(paused: Boolean) = Unit + override fun saveAccess() = HostSaveAccess(saveUnavailable, saveUnavailable) + override fun listSaves() = HostSaveCatalogResult.Rejected(saveUnavailable) + override fun saveGame(slot: String?, overwrite: Boolean) = HostSaveResult.Rejected(saveUnavailable) + override fun loadGame(slot: String) = HostSaveResult.Rejected(saveUnavailable) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt new file mode 100644 index 00000000..b51c0a86 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt @@ -0,0 +1,29 @@ +package org.demoth.cake.hosting + +/** A reason is null when the corresponding singleplayer UI action is available. */ +data class HostSaveAccess(val loadRejectionReason: String?, val saveRejectionReason: String?) + +/** Metadata inspection is not a guarantee that all gameplay references can be restored. */ +data class HostSaveEntry( + val slot: String, + val comment: String?, + val mapName: String?, + val savedAtMillis: Long?, + val mode: HostMode?, + val loadRejectionReason: String?, +) { + val autosave: Boolean get() = slot == "save0" +} + +sealed interface HostSaveCatalogResult { + data class Available(val profileId: String, val entries: List) : HostSaveCatalogResult + data class Rejected(val reason: String) : HostSaveCatalogResult + data class Failed(val reason: String, val cause: Exception) : HostSaveCatalogResult +} + +/** Completed means backend IO/restore completed; the client handshake may still be pending. */ +sealed interface HostSaveResult { + data class Completed(val slot: String) : HostSaveResult + data class Rejected(val reason: String) : HostSaveResult + data class Failed(val reason: String, val cause: Exception) : HostSaveResult +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 316e3fda..2c48159f 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -848,7 +848,7 @@ public int getPointContents(float[] p) { // currently share EngineWriteRoot/save/current; transitions and unit resets can overwrite // another Game's archives. Multi-Game persistence is explicitly deferred; save targets the default Game. /** Return a user-facing rejection reason, or null when this Game can be saved. */ - String saveRejectionReason() { + public String saveRejectionReason() { if (state != ServerStates.SS_GAME) return "You must be in a game to save."; if (isDeathmatch()) return "Can't savegame in a deathmatch"; diff --git a/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt b/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt new file mode 100644 index 00000000..a262b91e --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt @@ -0,0 +1,79 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.exec.CvarTypes +import jake2.server.save.ServerSaveJsonStore +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path + +enum class SavedGameMode { SINGLEPLAYER, COOP, DEATHMATCH } + +data class SavedGameEntry( + val slot: String, + val comment: String?, + val mapName: String?, + val savedAtMillis: Long?, + val mode: SavedGameMode?, + val problem: String?, +) + +/** Cheap, read-only browsing of an explicit root. Full restoration checks remain in SavePreflight. */ +class ServerSaveCatalog(private val root: Path) { + fun list(): List { + val directory = root.resolve("save") + if (Files.notExists(directory)) return emptyList() + return Files.newDirectoryStream(directory).use { paths -> + paths.filter { it.fileName.toString() != "current" && Files.isDirectory(it, NOFOLLOW_LINKS) } + .map { inspect(it.fileName.toString()) } + .sortedWith(compareByDescending { it.savedAtMillis ?: Long.MIN_VALUE }.thenBy { it.slot }) + } + } + + fun inspect(slot: String): SavedGameEntry { + var comment: String? = null + var mapName: String? = null + var timestamp: Long? = null + var mode: SavedGameMode? = null + val problem = try { + require(ServerSaveJsonStore.normalizeSlot(slot) == slot) { "Invalid save slot" } + require(slot != "current") { "The working save is internal" } + val directory = root.resolve("save").resolve(slot) + require(Files.isDirectory(directory, NOFOLLOW_LINKS)) { "Save directory is missing or is a link" } + val metadata = directory.resolve("server_mapcmd.ssv.json") + require(Files.isRegularFile(metadata, NOFOLLOW_LINKS)) { "Missing map metadata" } + timestamp = Files.getLastModifiedTime(metadata).toMillis() + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + val map = store.readMapCommand(slot) + require(map.schemaVersion() == ServerSaveJsonStore.SCHEMA_VERSION) { "Unsupported map metadata version" } + require(!map.mapCommand().isNullOrBlank()) { "Missing saved map command" } + val target = ChangeMapInfo(map.mapCommand(), false, true) + require(target.mapName.isNotBlank() && !target.mapName.contains("..") && + !target.mapName.contains('/') && !target.mapName.contains('\\')) { "Invalid saved map name" } + comment = map.comment() + mapName = target.mapName + require(Files.isRegularFile(directory.resolve("server_latched_cvars.ssv.json"), NOFOLLOW_LINKS)) { + "Missing saved settings" + } + val settings = store.readLatchedCvars(slot) + require(settings.schemaVersion() == ServerSaveJsonStore.SCHEMA_VERSION) { "Unsupported saved settings version" } + val values = settings.cvars().associate { it.name() to it.value() } + require(values.size == settings.cvars().size) { "Duplicate saved settings" } + val deathmatch = CvarTypes.BOOLEAN.parse(values.getValue("deathmatch")).value + val coop = CvarTypes.BOOLEAN.parse(values.getValue("coop")).value + val capacity = values.getValue("maxclients").toInt() + require(capacity in 1..Defines.MAX_CLIENTS) { "Invalid saved maxclients" } + mode = when { + deathmatch -> SavedGameMode.DEATHMATCH + coop -> SavedGameMode.COOP + else -> SavedGameMode.SINGLEPLAYER + } + require(mode != SavedGameMode.SINGLEPLAYER || capacity == 1) { "Invalid singleplayer client capacity" } + require(Files.isRegularFile(directory.resolve("game.ssv.json"), NOFOLLOW_LINKS)) { "Missing saved game state" } + null + } catch (failure: Exception) { + failure.message ?: "Unreadable save" + } + return SavedGameEntry(slot, comment, mapName, timestamp, mode, problem) + } +} diff --git a/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt new file mode 100644 index 00000000..89ef4fb0 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt @@ -0,0 +1,80 @@ +package jake2.server + +import jake2.server.save.ServerSaveJsonStore +import jake2.server.save.ServerSaveJsonStore.LatchedCvarSnapshot +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime + +class ServerSaveCatalogTest { + @TempDir lateinit var root: Path + + private fun metadata(slot: String, time: Long, coop: Boolean = false, deathmatch: Boolean = false) { + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + store.writeMapCommand(slot, "Checkpoint $slot", "*testbox") + store.writeLatchedCvars(slot, listOf( + LatchedCvarSnapshot("maxclients", if (coop || deathmatch) "4" else "1"), + LatchedCvarSnapshot("coop", if (coop) "1" else "0"), + LatchedCvarSnapshot("deathmatch", if (deathmatch) "1" else "0"), + )) + Files.writeString(root.resolve("save/$slot/game.ssv.json"), "{}") + Files.setLastModifiedTime(root.resolve("save/$slot/server_mapcmd.ssv.json"), FileTime.fromMillis(time)) + } + + @Test + fun `missing save directory is empty and listing does not create it`() { + assertEquals(emptyList(), ServerSaveCatalog(root).list()) + assertFalse(Files.exists(root.resolve("save"))) + } + + @Test + fun `catalog sorts checkpoints retains autosave and isolates malformed entries`() { + metadata("current", 5000) + metadata("console checkpoint", 1000) + metadata("save0", 3000) + metadata("coop", 2000, coop = true) + metadata("deathmatch", 2000, deathmatch = true) + metadata("broken", 4000) + Files.writeString(root.resolve("save/broken/server_mapcmd.ssv.json"), "{broken") + Files.createDirectories(root.resolve("save/incomplete")) + Files.writeString(root.resolve("save/readme.txt"), "not a slot") + val entries = ServerSaveCatalog(root).list() + assertFalse(entries.any { it.slot == "current" || it.slot == "readme.txt" }) + assertEquals(listOf("save0", "coop", "deathmatch", "console checkpoint"), + entries.filter { it.problem == null }.map { it.slot }) + assertEquals("testbox", entries.first { it.slot == "save0" }.mapName) + assertEquals(SavedGameMode.COOP, entries.first { it.slot == "coop" }.mode) + assertEquals(SavedGameMode.DEATHMATCH, entries.first { it.slot == "deathmatch" }.mode) + assertNotNull(entries.first { it.slot == "broken" }.problem) + assertNotNull(entries.first { it.slot == "incomplete" }.problem) + } + + @Test + fun `bad versions settings and missing gameplay file do not appear usable`() { + metadata("version", 1) + Files.writeString(root.resolve("save/version/server_mapcmd.ssv.json"), + """{"schemaVersion":99,"comment":"old","mapCommand":"testbox"}""") + metadata("settings", 2) + Files.writeString(root.resolve("save/settings/server_latched_cvars.ssv.json"), "{}") + metadata("missing", 3) + Files.delete(root.resolve("save/missing/game.ssv.json")) + assertTrue(ServerSaveCatalog(root).list().all { it.problem != null }) + assertNotNull(ServerSaveCatalog(root).inspect("../outside").problem) + } + + @Test + fun `slot links are not browsed and save root failures are not mistaken for empty catalogs`() { + val outside = Files.createDirectory(root.resolve("outside")) + Files.createDirectory(root.resolve("save")) + Files.createSymbolicLink(root.resolve("save/link"), outside) + assertTrue(ServerSaveCatalog(root).list().isEmpty()) + assertNotNull(ServerSaveCatalog(root).inspect("link").problem) + Files.delete(root.resolve("save/link")) + Files.delete(root.resolve("save")) + Files.writeString(root.resolve("save"), "not a directory") + assertThrows(java.io.IOException::class.java) { ServerSaveCatalog(root).list() } + } +} From 46bb34d6cb9d26a021c6996ddf67ba8f11d870d4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:30:44 +0100 Subject: [PATCH 07/57] Preserve campaign autosaves when hosting deathmatch --- .../jake2/server/GameContinuationTest.kt | 29 +++++++++++++++++++ .../server/HostSessionPreparationTest.kt | 25 ++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 4 +-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 81dcf2c3..d68b4c19 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -68,6 +68,35 @@ class GameContinuationTest { private fun finish(client: ClientNetworkInfo, game: GameImportsImpl) = host.executeClientCommand(client, "nextserver ${game.spawncount}") + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `continuation autosave eligibility follows its Game mode`(deathmatch: Boolean) { + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) + val autosave = root.resolve("save/save0") + fun contents() = Files.walk(autosave).use { paths -> paths.filter(Files::isRegularFile).toList() + .associate { autosave.relativize(it).toString() to Files.readAllBytes(it).toList() } } + val before = contents() + assertTrue(before.isNotEmpty()) + host.stop("cinematic", false) + val cvars = Cvar.getInstance() + cvars.find("deathmatch").forceSet(deathmatch) + cvars.find("maxclients").forceSet(if (deathmatch) 3 else 1) + host.resetClients() + val cinematic = game("default", "intro.cin+testbox2") + // Changed Host defaults must not decide persistence for an existing Game. + cvars.find("deathmatch").forceSet(!deathmatch) + finish(bind(0, cinematic), cinematic) + host.update(100) + val replacement = host.games.getValue("default") + assertEquals("testbox2", replacement.mapName) + assertEquals(deathmatch, replacement.isDeathmatch) + if (deathmatch) assertEquals(before, contents()) else { + assertNotEquals(before, contents()) + assertEquals("testbox2", jake2.server.save.ServerSaveJsonStore.forWriteDir(root.toString()) + .readMapCommand("save0").mapCommand()) + } + } + @Test fun `live timelimit ends only its owning Game`() { Cvar.getInstance().find("deathmatch").forceSet(true) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index b7d43a4f..e9611862 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -54,6 +54,31 @@ class HostSessionPreparationTest { ServerSaveFiles(root).copy("current", "slot") } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `deathmatch map and gamemap never create or overwrite autosave`(existingSingleplayerSave: Boolean) { + if (existingSingleplayerSave) { + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) + host.stop("start deathmatch", false) + } + val autosave = root.resolve("save/save0") + fun contents(): Map> = if (!Files.exists(autosave)) emptyMap() else + Files.walk(autosave).use { paths -> paths.filter(Files::isRegularFile).toList() + .associate { autosave.relativize(it).toString() to Files.readAllBytes(it).toList() } } + val before = contents() + assertEquals(existingSingleplayerSave, before.isNotEmpty()) + cvars.find("deathmatch").forceSet(true) + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) + assertTrue(host.games.getValue("default").isDeathmatch) + assertEquals(before, contents()) + assertEquals(ServerHost.OperationResult.Completed, host.changeMap("testbox2")) + assertEquals("testbox2", host.games.getValue("default").mapName) + assertEquals(before, contents()) + assertEquals(existingSingleplayerSave, Files.exists(autosave)) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("manual-dm")) + assertFalse(Files.exists(root.resolve("save/manual-dm"))) + } + @ParameterizedTest @ValueSource(booleans = [false, true]) fun `load after stop prepares saved capacity before Game allocation`(coop: Boolean) { diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index f0270d7a..f403e824 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -644,7 +644,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } // copy off the level to the autosave slot - if (!Globals.dedicated.typedValue) { + if (!Globals.dedicated.typedValue && !game.isDeathmatch) { game.SV_WriteServerFile(true) ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") } @@ -1619,7 +1619,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) val replacement = spawnServerInstance(target, previous, routedClients) replacement.name = previous.name games[replacement.name] = replacement - if (!Globals.dedicated.typedValue) { + if (!Globals.dedicated.typedValue && !replacement.isDeathmatch) { replacement.SV_WriteServerFile(true) ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") } From efa28400021308efc17f5c8049dba7ee54e20412 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:40:13 +0100 Subject: [PATCH 08/57] Add singleplayer save/load menus and death recovery --- cake/README.md | 10 + .../jake2/server/CakeSaveMenusGlTest.kt | 232 ++++++++++++++++++ .../src/main/kotlin/org/demoth/cake/Cake.kt | 31 +++ .../org/demoth/cake/stages/MainMenuStage.kt | 16 ++ .../org/demoth/cake/stages/MenuStages.kt | 2 + .../org/demoth/cake/stages/SavedGamesStage.kt | 125 ++++++++++ .../org/demoth/cake/ui/menu/MenuController.kt | 98 +++++++- .../org/demoth/cake/ui/menu/MenuIntent.kt | 9 + .../org/demoth/cake/ui/menu/MenuSignal.kt | 2 + .../org/demoth/cake/ui/menu/MenuState.kt | 16 ++ .../org/demoth/cake/ui/MenuStagesGlTest.kt | 48 ++++ .../demoth/cake/ui/menu/MenuControllerTest.kt | 143 +++++++++++ 12 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt diff --git a/cake/README.md b/cake/README.md index 6f8b01c9..6e2ff929 100644 --- a/cake/README.md +++ b/cake/README.md @@ -133,6 +133,16 @@ The Host Game menu configures these settings for you. Saves are stored in `~/.cake//save/`. Console `load ` restores a game even while disconnected. `save ` requires an active game. +Fullgame's main menu provides Load Game and Save Game for singleplayer. New Save +creates a checkpoint; Overwrite asks before replacing a manual save. Autosave is +listed for loading and reserved against manual overwrite. Save requires a living, +spawned local player. Loading is available from startup and during singleplayer; +the singleplayer respawn action opens Load Game automatically. + +`menu_loadgame` and `menu_savegame` open those screens from the console. Errors +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. + ## Subsystem Guides - [Desktop video configuration](cake-client/README.md) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt new file mode 100644 index 00000000..14d97eea --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt @@ -0,0 +1,232 @@ +package jake2.server + +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener +import jake2.game.GameEntity +import jake2.game.GameExportsImpl +import jake2.game.PlayerClient +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cbuf +import jake2.qcommon.exec.Cmd +import org.demoth.cake.Cake +import org.demoth.cake.CakeStartupBootstrap +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.HostState +import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.profile.CakeGameProfileStore +import org.demoth.cake.stages.MenuStages +import org.demoth.cake.ui.menu.MenuEventBus +import org.demoth.cake.ui.menu.MenuScreen +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +/** Clicks real menu widgets and follows the real singleplayer respawn command through Cbuf. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +class CakeSaveMenusGlTest { + @TempDir lateinit var temp: Path + + @Test + fun `save menus confirm overwrite recover from death and load failure and follow profile switches`() { + val content = requireNotNull(System.getenv("CAKE_TEST_BASEDIR")) + val previousHome = System.getProperty("user.home") + System.setProperty("user.home", temp.toString()) + val host = EmbeddedCakeHost(temp.resolve(".cake")) + var failure: Throwable? = null + try { + CakeStartupBootstrap.ensureInitialized() + CakeGameProfileStore().upsertProfile(CakeGameProfile("first", content)) + val startup = CakeStartupBootstrap.bootstrap() + Lwjgl3Application(object : ApplicationAdapter() { + private lateinit var cake: Cake + private lateinit var bus: MenuEventBus + private var step = 0 + private var ticks = 0 + private var frozenFrame = 0 + private lateinit var slot: String + private lateinit var originalSave: String + private val deadline = System.nanoTime() + 90_000_000_000L + + override fun create() { + try { + cake = Cake(startup, host = host) + cake.create() + bus = field("menuEventBus") as MenuEventBus + Cmd.ExecuteString("menu_loadgame") + } catch (error: Throwable) { fail(error) } + } + + private fun field(name: String): Any? = Cake::class.java.getDeclaredField(name).apply { + isAccessible = true + }.get(cake) + private fun game() = host.server!!.games.getValue("default").gameExports as GameExportsImpl + private fun player() = game().getEdict(1) as GameEntity + private fun active() = field("networkState").toString() == "ACTIVE" && ticks > 3 + private fun state() = bus.latestState().savedGames + private fun stage() = (field("menuStages") as MenuStages).inputProcessor as Stage + private fun button(name: String): TextButton = requireNotNull(stage().root.findActor(name)) + private fun click(name: String) { + val button = button(name) + assertFalse(button.isDisabled, "Button $name disabled at step $step") + button.listeners.filterIsInstance().forEach { it.clicked(InputEvent(), 0f, 0f) } + } + private fun next() { step++; ticks = 0 } + private fun checkpoint() = temp.resolve(".cake/first/save/$slot/game.ssv.json") + private fun blocker() = temp.resolve(".cake/first/save/current/game.ssv.json") + private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } + + override fun render() { + if (failure != null) return + try { + assertTrue(System.nanoTime() < deadline, "Timed out at step $step") + cake.render() + ticks++ + when (step) { + 0 -> { + assertEquals(MenuScreen.LOAD_GAME, bus.latestState().activeScreen) + assertTrue(state().entries.isEmpty()) + assertTrue(button("load-game").isDisabled) + assertEquals(true, field("menuVisible")) + stage().keyUp(Input.Keys.ESCAPE) + Cbuf.AddText("map base1") + next() + } + 1 -> if (active()) { + player().health = 73 + Cmd.ExecuteString("menu_savegame") + next() + } + 2 -> { + assertEquals(MenuScreen.SAVE_GAME, bus.latestState().activeScreen) + click("save-new") + next() + } + 3 -> { + assertEquals("Game saved.", state().statusMessage) + slot = state().selectedSlot!! + originalSave = Files.readString(checkpoint()) + player().health = 41 + click("save-overwrite") + next() + } + 4 -> { + assertEquals(slot, state().pendingOverwriteSlot) + click("cancel-overwrite") + next() + } + 5 -> { + assertNull(state().pendingOverwriteSlot) + assertEquals(originalSave, Files.readString(checkpoint())) + click("save-overwrite") + next() + } + 6 -> { click("confirm-overwrite"); next() } + 7 -> { + assertEquals("Game saved.", state().statusMessage) + assertNotEquals(originalSave, Files.readString(checkpoint())) + player().health = 5 + Cmd.ExecuteString("menu_loadgame") + next() + } + 8 -> { click("load-game"); next() } + 9 -> if (active()) { + assertEquals(41, player().health) + assertEquals(false, field("menuVisible")) + player().health = 0 + player().getClient().playerState.stats[Defines.STAT_HEALTH] = 0 + PlayerClient.respawn(player(), game()) + next() + } + 10 -> if (ticks > 2) { + assertEquals(MenuScreen.LOAD_GAME, bus.latestState().activeScreen) + assertEquals(true, field("menuVisible")) + assertTrue(host.server!!.isPaused) + frozenFrame = game().level.framenum + Cmd.ExecuteString("menu_savegame") + next() + } + 11 -> { + assertEquals(frozenFrame, game().level.framenum) + assertTrue(button("save-new").isDisabled) + assertTrue(bus.latestState().saveAccess.saveRejectionReason!!.contains("dead")) + Cmd.ExecuteString("menu_loadgame") + next() + } + 12 -> { + // Valid source, but failure while replacing current must leave feedback visible. + Files.delete(blocker()) + Files.createDirectory(blocker()) + Files.writeString(blocker().resolve("blocker"), "force copy failure") + click("load-game") + next() + } + 13 -> if (ticks > 2) { + assertTrue(state().statusMessage.startsWith("Load failed:")) + assertEquals(HostState.STOPPED, host.state) + assertEquals("DISCONNECTED", field("networkState").toString()) + assertEquals(true, field("menuVisible")) + assertEquals(false, field("consoleVisible")) + Files.delete(blocker().resolve("blocker")) + Files.delete(blocker()) + click("load-game") + next() + } + 14 -> if (active()) { + assertEquals(41, player().health) + Cmd.ExecuteString("disconnect") + Cmd.ExecuteString("cake_profile_set second \"$content\"") + Cmd.ExecuteString("menu_loadgame") + next() + } + 15 -> { + assertEquals("second", state().profileId) + assertTrue(state().entries.isEmpty()) + assertTrue(button("load-game").isDisabled) + Cmd.ExecuteString("cake_profile_set first \"$content\"") + Cmd.ExecuteString("menu_loadgame") + next() + } + 16 -> { + assertEquals("first", state().profileId) + assertTrue(state().entries.any { it.slot == slot }) + click("load-game") + next() + } + 17 -> if (active()) { + assertEquals(41, player().health) + Gdx.app.exit() + } + } + } catch (error: Throwable) { fail(error) } + } + + 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) + } + } +} 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 e4b92c8a..c63f0736 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -382,6 +382,12 @@ class Cake( } Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { disconnect() } + Cmd.AddCommand("menu_loadgame", "Open the Load Game menu") { + menuEventBus.postIntent(MenuIntent.OpenLoadGame) + } + Cmd.AddCommand("menu_savegame", "Open the Save Game menu") { + menuEventBus.postIntent(MenuIntent.OpenSaveGame) + } Cmd.AddCommand("loading", "(internal) Prepare local map-change presentation") { if (host.state != HostState.STOPPED && game3dScreen != null) { @@ -976,6 +982,16 @@ class Cake( private fun drainMenuSignals(maxItems: Int = MenuEventBus.DEFAULT_DRAIN_LIMIT) { menuEventBus.drainSignals(maxItems) { signal -> when (signal) { + MenuSignal.ShowMenu -> { + menuVisible = true + consoleVisible = false + updateInputHandlers(consoleVisible, menuVisible) + } + MenuSignal.CloseMenu -> { + menuVisible = false + consoleVisible = false + updateInputHandlers(consoleVisible, menuVisible) + } is MenuSignal.OpenUiSample -> openUiSample(signal.game) MenuSignal.CloseUiSample -> closeUiSample() is MenuSignal.CaptureUiSample -> { @@ -1572,6 +1588,21 @@ class Cake( } private fun createMenuBackend(): MenuBackend = object : MenuBackend { + override fun saveAccess(): HostSaveAccess = host.saveAccess() + override fun listSaves(): HostSaveCatalogResult = host.listSaves() + override fun canResumeGame(): Boolean = networkState == ACTIVE && game3dScreen != null + override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = + finishSaveOperation(host.saveGame(slot, overwrite)) + override fun loadGame(slot: String): HostSaveResult = finishSaveOperation(host.loadGame(slot)) + + private fun finishSaveOperation(result: HostSaveResult): HostSaveResult { + if (result is HostSaveResult.Failed && host.state == HostState.STOPPED) { + // Late failure sent disconnect packets; clear the old client before presenting the error. + disconnect() + } + return result + } + override fun hostingUnavailableReason(): String? = when { host.availability != HostAvailability.AVAILABLE -> "Hosting requires Cake fullgame." activeGameProfile == null -> "Select a game profile first." diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt index a2f8ab38..1deda330 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt @@ -3,6 +3,7 @@ package org.demoth.cake.stages import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.exec.Cbuf import org.demoth.cake.BuildVersion @@ -40,6 +41,8 @@ class MainMenuStage( private var currentProfileLabel: Label private var switchProfileButton: TextButton private var singleplayerButton: TextButton? = null + private var loadButton: TextButton? = null + private var saveButton: TextButton? = null private var disconnectButton: TextButton private var renderedProfileId: String = "" private var lastCanDisconnectState: Boolean = false @@ -72,6 +75,17 @@ class MainMenuStage( } add(singleplayerButton) row() + loadButton = createMenuButton("Load Game", buttonStyle) { + menuEventBus.postIntent(MenuIntent.OpenLoadGame) + }.apply { labelCell.minWidth(0f) } + saveButton = createMenuButton("Save Game", buttonStyle) { + menuEventBus.postIntent(MenuIntent.OpenSaveGame) + }.apply { labelCell.minWidth(0f) } + add(Table().apply { + add(loadButton).growX().padRight(4f) + add(saveButton).growX().padLeft(4f) + }) + row() } add(createMenuButton("Multiplayer", buttonStyle) { @@ -105,6 +119,8 @@ class MainMenuStage( super.act(delta) val snapshot = menuEventBus.latestState() singleplayerButton?.isDisabled = !snapshot.canHostGame + loadButton?.isDisabled = snapshot.saveAccess.loadRejectionReason != null + saveButton?.isDisabled = snapshot.saveAccess.saveRejectionReason != null val state = snapshot.mainMenu val active = state.activeProfileId.trim() if (active != renderedProfileId) { 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 d37d540f..7ad7216c 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 @@ -48,6 +48,8 @@ class MenuStages( for (screen in MenuScreen.entries) { stages[screen] = when (screen) { MenuScreen.MAIN -> MainMenuStage(viewport, menuEventBus, style, showHosting) + MenuScreen.LOAD_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = false) + MenuScreen.SAVE_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = true) MenuScreen.PROFILE_EDIT -> ProfileEditStage(viewport, menuEventBus, style) MenuScreen.MULTIPLAYER -> MultiplayerMenuStage(viewport, menuEventBus, style, showHosting) MenuScreen.SINGLEPLAYER -> HostingSetupStage(viewport, menuEventBus, style, true) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt new file mode 100644 index 00000000..cc893703 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -0,0 +1,125 @@ +package org.demoth.cake.stages + +import com.badlogic.gdx.Input +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.SelectBox +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import com.badlogic.gdx.utils.viewport.Viewport +import org.demoth.cake.hosting.HostSaveEntry +import org.demoth.cake.ui.GameUiStyle +import org.demoth.cake.ui.menu.* +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Shared Load/Save presentation; catalog IO and overwrite decisions belong to the controller. */ +class SavedGamesStage( + viewport: Viewport, + menuEventBus: MenuEventBus, + style: GameUiStyle, + private val saving: Boolean, +) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { + private data class Choice(val entry: HostSaveEntry) { + override fun toString(): String { + val date = entry.savedAtMillis?.let { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(it)) + }.orEmpty() + val title = entry.mapName ?: entry.slot + return "${if (entry.autosave) "Autosave: " else ""}$title $date" + } + } + + private var syncing = false + private var renderedEntries: List? = null + private val profile = Label("", style.menuWidgets.label) + private val slots = SelectBox(style.menuWidgets.selectBox).apply { name = "save-slots"; maxListCount = 10 } + private val detail = Label("", style.menuWidgets.label).apply { setWrap(true) } + private val status = Label("", style.menuWidgets.label).apply { name = "save-status"; setWrap(true) } + private val primary = createMenuButton(if (saving) "New Save" else "Load Game", style.menuWidgets.button) { + val state = menuEventBus.latestState().savedGames + if (saving) menuEventBus.postIntent(MenuIntent.SaveGameRequested(state.profileId)) + else state.selectedSlot?.let { menuEventBus.postIntent(MenuIntent.LoadGameRequested(state.profileId, it)) } + }.apply { name = if (saving) "save-new" else "load-game" } + private val overwrite = createMenuButton("Overwrite", style.menuWidgets.button) { + val state = menuEventBus.latestState().savedGames + state.selectedSlot?.let { menuEventBus.postIntent(MenuIntent.SaveGameRequested(state.profileId, it)) } + }.apply { name = "save-overwrite" } + private val confirmation = Table().apply { + add(createMenuButton("Replace Save", style.menuWidgets.button) { + val state = menuEventBus.latestState().savedGames + state.pendingOverwriteSlot?.let { menuEventBus.postIntent(MenuIntent.ConfirmOverwrite(state.profileId, it)) } + }.apply { name = "confirm-overwrite" }).pad(4f) + add(createMenuButton("Cancel", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.CancelOverwrite) + }.apply { name = "cancel-overwrite" }).pad(4f) + } + private val resume = createMenuButton("Resume Game", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.ResumeGame) + } + + init { + slots.addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { + if (!syncing) slots.selected?.let { + menuEventBus.postIntent(MenuIntent.SelectSaveSlot(menuEventBus.latestState().savedGames.profileId, it.entry.slot)) + } + } + }) + val root = Table().apply { + defaults().pad(6f).left() + add(Label(if (saving) "Save Game" else "Load Game", style.menuWidgets.label)).colspan(2).row() + add(profile).colspan(2).row() + add(slots).colspan(2).width(560f).row() + add(detail).colspan(2).width(560f).row() + add(primary) + if (saving) add(overwrite) else add(createMenuButton("Refresh", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.RefreshSavedGames) + }) + row() + add(confirmation).colspan(2).row() + add(status).colspan(2).width(560f).row() + add(resume); add(createBackButton(style = style.menuWidgets.button)).row() + } + addCenteredForm(root, style.menuWidgets.scrollPane) + } + + override fun act(delta: Float) { + super.act(delta) + val snapshot = menuEventBus.latestState() + val state = snapshot.savedGames + syncing = true + if (renderedEntries !== state.entries) { + renderedEntries = state.entries + slots.hideScrollPane() + slots.setItems(*state.entries.map(::Choice).toTypedArray()) + } + slots.items.firstOrNull { it.entry.slot == state.selectedSlot }?.let { slots.selected = it } + syncing = false + val selected = state.entries.firstOrNull { it.slot == state.selectedSlot } + val pending = state.pendingOverwriteSlot != null + val reason = if (saving) snapshot.saveAccess.saveRejectionReason else snapshot.saveAccess.loadRejectionReason + profile.setText("Profile: ${state.profileId.ifBlank { "" }}") + slots.isDisabled = state.entries.isEmpty() || pending + primary.isDisabled = pending || reason != null || (!saving && (selected == null || selected.loadRejectionReason != null)) + overwrite.isDisabled = pending || reason != null || selected == null || selected.autosave + confirmation.isVisible = pending + confirmation.cells.forEach { it.height(if (pending) 68f else 0f) } + resume.isDisabled = !snapshot.canResumeGame + detail.setText(when { + state.entries.isEmpty() -> "No saved games for this profile." + saving && selected?.autosave == true -> "Autosave is reserved. Choose New Save or another slot." + else -> listOfNotNull(selected?.comment, selected?.loadRejectionReason).joinToString("\n") + }) + status.setText(state.statusMessage.ifBlank { reason.orEmpty() }) + } + + override fun keyUp(keycode: Int): Boolean { + if (keycode == Input.Keys.ESCAPE && menuEventBus.latestState().savedGames.pendingOverwriteSlot != null) { + menuEventBus.postIntent(MenuIntent.CancelOverwrite) + return true + } + return super.keyUp(keycode) + } +} 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 49e7206d..26c6eea1 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 @@ -2,8 +2,14 @@ package org.demoth.cake.ui.menu import jake2.qcommon.Defines.PORT_SERVER import jake2.qcommon.network.netadr_t +import org.demoth.cake.hosting.* interface MenuBackend { + fun saveAccess(): HostSaveAccess = HostSaveAccess("Save/load is unavailable.", "Save/load is unavailable.") + fun listSaves(): HostSaveCatalogResult = HostSaveCatalogResult.Rejected("Save/load is unavailable.") + fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = HostSaveResult.Rejected("Saving is unavailable.") + fun loadGame(slot: String): HostSaveResult = HostSaveResult.Rejected("Loading is unavailable.") + fun canResumeGame(): Boolean = false fun hostMaps(): List = emptyList() fun hostingUnavailableReason(): String? = "Hosting requires the Cake fullgame application." fun startHosting(singleplayer: Boolean, form: HostGameFormState): String = @@ -66,6 +72,12 @@ class MenuController( fun refreshExternalState() { if (hostingMapsDirty && state.activeScreen == MenuScreen.HOST_GAME) refreshHostingMaps() + if (state.savedGames.profileId != backend.activeProfileId()) { + if (isSaveScreen()) refreshSavedGames() else state = state.copy(savedGames = SavedGamesState()) + } + if (backend.saveAccess().saveRejectionReason != null && state.savedGames.pendingOverwriteSlot != null) { + state = state.copy(savedGames = state.savedGames.copy(pendingOverwriteSlot = null)) + } refreshState( activeScreen = state.activeScreen, formOverride = state.profileEditor.form, @@ -75,6 +87,47 @@ class MenuController( private fun handleIntent(intent: MenuIntent) { when (intent) { + MenuIntent.OpenLoadGame, MenuIntent.OpenSaveGame -> { + state = state.copy(activeScreen = if (intent == MenuIntent.OpenLoadGame) MenuScreen.LOAD_GAME else MenuScreen.SAVE_GAME) + refreshSavedGames() + refreshState() + bus.postSignal(MenuSignal.ShowMenu) + } + MenuIntent.RefreshSavedGames -> if (isSaveScreen()) { + refreshSavedGames() + refreshState() + } + is MenuIntent.SelectSaveSlot -> if (isCurrentSaveProfile(intent.profileId) && + state.savedGames.entries.any { it.slot == intent.slot }) { + state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, + pendingOverwriteSlot = null, statusMessage = "")) + refreshState() + } + is MenuIntent.LoadGameRequested -> { + if (state.activeScreen != MenuScreen.LOAD_GAME || !isCurrentSaveProfile(intent.profileId)) return + val entry = state.savedGames.entries.firstOrNull { it.slot == intent.slot } ?: return + val reason = backend.saveAccess().loadRejectionReason ?: entry.loadRejectionReason + if (reason != null) saveStatus(reason) else handleSaveResult(backend.loadGame(intent.slot), loading = true) + } + is MenuIntent.SaveGameRequested -> { + if (state.activeScreen != MenuScreen.SAVE_GAME || !isCurrentSaveProfile(intent.profileId)) return + val reason = backend.saveAccess().saveRejectionReason + if (reason != null) saveStatus(reason) else if (intent.slot == null) { + handleSaveResult(backend.saveGame(null, overwrite = false), loading = false) + } else if (state.savedGames.entries.any { it.slot == intent.slot && !it.autosave }) { + state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, + pendingOverwriteSlot = intent.slot, statusMessage = "Replace this saved game?")) + refreshState() + } + } + is MenuIntent.ConfirmOverwrite -> { + if (state.activeScreen != MenuScreen.SAVE_GAME || !isCurrentSaveProfile(intent.profileId) || + state.savedGames.pendingOverwriteSlot != intent.slot) return + val reason = backend.saveAccess().saveRejectionReason + if (reason != null) saveStatus(reason) else handleSaveResult(backend.saveGame(intent.slot, overwrite = true), loading = false) + } + MenuIntent.CancelOverwrite -> saveStatus("") + MenuIntent.ResumeGame -> if (backend.canResumeGame()) bus.postSignal(MenuSignal.CloseMenu) is MenuIntent.OpenUiSample -> bus.postSignal(MenuSignal.OpenUiSample(intent.game)) MenuIntent.CloseUiSample -> bus.postSignal(MenuSignal.CloseUiSample) is MenuIntent.CaptureUiSample -> @@ -354,6 +407,46 @@ class MenuController( } } + private fun isSaveScreen() = state.activeScreen == MenuScreen.LOAD_GAME || state.activeScreen == MenuScreen.SAVE_GAME + + private fun isCurrentSaveProfile(profileId: String): Boolean = + profileId == backend.activeProfileId() && profileId == state.savedGames.profileId + + private fun refreshSavedGames(selectedSlot: String? = state.savedGames.selectedSlot) { + val profileId = backend.activeProfileId() + val catalog = backend.listSaves() + val entries = (catalog as? HostSaveCatalogResult.Available)?.takeIf { it.profileId == profileId }?.entries.orEmpty() + val status = when (catalog) { + is HostSaveCatalogResult.Available -> if (catalog.profileId == profileId) "" else "The selected profile changed. Refresh saved games." + is HostSaveCatalogResult.Rejected -> catalog.reason + is HostSaveCatalogResult.Failed -> "Could not list saved games: ${catalog.reason}" + } + val selected = selectedSlot?.takeIf { state.savedGames.profileId == profileId && entries.any { entry -> entry.slot == it } } + ?: entries.firstOrNull { if (state.activeScreen == MenuScreen.SAVE_GAME) !it.autosave else it.loadRejectionReason == null }?.slot + ?: entries.firstOrNull()?.slot + state = state.copy(savedGames = SavedGamesState(profileId, entries, selected, statusMessage = status)) + } + + private fun saveStatus(message: String) { + state = state.copy(savedGames = state.savedGames.copy(statusMessage = message, pendingOverwriteSlot = null)) + refreshState() + } + + private fun handleSaveResult(result: HostSaveResult, loading: Boolean) { + when (result) { + is HostSaveResult.Completed -> { + refreshSavedGames(result.slot) + saveStatus(if (loading) "Loading game..." else "Game saved.") + if (loading) bus.postSignal(MenuSignal.CloseMenu) + } + is HostSaveResult.Rejected -> saveStatus(result.reason) + is HostSaveResult.Failed -> { + saveStatus("${if (loading) "Load" else "Save"} failed: ${result.reason}") + bus.postSignal(MenuSignal.ShowMenu) + } + } + } + private fun refreshState( activeScreen: MenuScreen = state.activeScreen, formOverride: ProfileFormState? = null, @@ -430,6 +523,9 @@ class MenuController( state = MenuStateSnapshot( activeScreen = activeScreen, canHostGame = backend.hostingUnavailableReason() == null, + canResumeGame = backend.canResumeGame(), + saveAccess = backend.saveAccess(), + savedGames = state.savedGames, singleplayer = state.singleplayer, hostGame = state.hostGame, mainMenu = mainMenuState, @@ -458,7 +554,7 @@ class MenuController( return when (screen) { MenuScreen.MAIN -> 0 MenuScreen.PROFILE_EDIT, MenuScreen.MULTIPLAYER, MenuScreen.OPTIONS, MenuScreen.SINGLEPLAYER, - MenuScreen.CONTENT_BROWSER -> 1 + MenuScreen.CONTENT_BROWSER, MenuScreen.LOAD_GAME, MenuScreen.SAVE_GAME -> 1 MenuScreen.JOIN_GAME, MenuScreen.PLAYER_SETUP, MenuScreen.OPTIONS_SECTION, MenuScreen.HOST_GAME -> 2 } } 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 1d5fcc81..e111c404 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 @@ -10,6 +10,15 @@ sealed interface MenuIntent { data object RequestStateSync : MenuIntent data object OpenProfileEditor : MenuIntent data object OpenMainMenu : MenuIntent + data object OpenLoadGame : MenuIntent + data object OpenSaveGame : MenuIntent + data object RefreshSavedGames : MenuIntent + data class SelectSaveSlot(val profileId: String, val slot: String) : MenuIntent + data class LoadGameRequested(val profileId: String, val slot: String) : MenuIntent + data class SaveGameRequested(val profileId: String, val slot: String? = null) : MenuIntent + data class ConfirmOverwrite(val profileId: String, val slot: String) : MenuIntent + data object CancelOverwrite : MenuIntent + data object ResumeGame : MenuIntent data object OpenMultiplayerMenu : MenuIntent data class OpenHostingSetup(val singleplayer: Boolean) : MenuIntent data class StartHosting(val singleplayer: Boolean, val form: HostGameFormState) : 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 f8e6fc15..b1d80112 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 @@ -9,6 +9,8 @@ enum class MenuUiSoundEffect { } sealed interface MenuSignal { + data object ShowMenu : MenuSignal + data object CloseMenu : 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/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt index 5ca2be4c..7a39a159 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt @@ -1,7 +1,12 @@ package org.demoth.cake.ui.menu +import org.demoth.cake.hosting.HostSaveAccess +import org.demoth.cake.hosting.HostSaveEntry + enum class MenuScreen { MAIN, + LOAD_GAME, + SAVE_GAME, PROFILE_EDIT, MULTIPLAYER, SINGLEPLAYER, @@ -73,6 +78,9 @@ data class OptionsSectionState( data class MenuStateSnapshot( val activeScreen: MenuScreen = MenuScreen.MAIN, val canHostGame: Boolean = false, + val canResumeGame: Boolean = false, + val saveAccess: HostSaveAccess = HostSaveAccess("Save/load is unavailable.", "Save/load is unavailable."), + val savedGames: SavedGamesState = SavedGamesState(), val singleplayer: HostingSetupState = HostingSetupState(), val hostGame: HostingSetupState = HostingSetupState(), val mainMenu: MainMenuState = MainMenuState(), @@ -84,6 +92,14 @@ data class MenuStateSnapshot( val optionsSection: OptionsSectionState = OptionsSectionState(), ) +data class SavedGamesState( + val profileId: String = "", + val entries: List = emptyList(), + val selectedSlot: String? = null, + val pendingOverwriteSlot: String? = null, + val statusMessage: String = "", +) + /** Raw text stays in the form so rejected input can be corrected without losing it. */ data class HostGameFormState( val map: String = "", 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 f9f4e4a5..0b043668 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 @@ -15,6 +15,11 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.scenes.scene2d.InputEvent import com.badlogic.gdx.scenes.scene2d.utils.ClickListener import org.demoth.cake.stages.HostingSetupStage +import org.demoth.cake.stages.SavedGamesStage +import org.demoth.cake.hosting.HostSaveAccess +import org.demoth.cake.hosting.HostSaveEntry +import org.demoth.cake.hosting.HostMode +import org.demoth.cake.ui.menu.SavedGamesState import org.demoth.cake.ui.menu.HostMap import org.demoth.cake.ui.menu.HostGameFormState import org.demoth.cake.ui.menu.HostingSetupState @@ -65,6 +70,7 @@ class MenuStagesGlTest { val menus = MenuStages(viewport, bus, assets, CakeFileResolver(), false) { null } try { verifyHostMapSelection(viewport, style) + verifySavedGames(viewport, style) menus.rebuild(style) val main = menus.inputProcessor assertFalse(menus.select(MenuScreen.MAIN)) @@ -142,6 +148,48 @@ class MenuStagesGlTest { } } + private fun verifySavedGames(viewport: UiViewport, style: GameUiStyle) { + val entries = listOf( + HostSaveEntry("save0", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null), + HostSaveEntry("manual", "Checkpoint", "base2", 2000L, HostMode.SINGLEPLAYER, null), + ) + val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, + canResumeGame = true, saveAccess = HostSaveAccess(null, null), + savedGames = SavedGamesState("profile", entries, "save0"))) + val stage = SavedGamesStage(viewport, bus, style, saving = true) + try { + stage.act(0f) + stage.draw() + val primary = stage.root.findActor("save-new") + val overwrite = stage.root.findActor("save-overwrite") + assertFalse(primary.isDisabled) + assertTrue(overwrite.isDisabled) + bus.postState(bus.latestState().copy(savedGames = bus.latestState().savedGames.copy( + selectedSlot = "manual", pendingOverwriteSlot = "manual", statusMessage = "Replace this saved game?"))) + stage.act(0f) + stage.draw() + assertTrue(primary.isDisabled) + assertTrue(overwrite.isDisabled) + val confirm = stage.root.findActor("confirm-overwrite") + val position = confirm.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) + assertTrue(confirm.width > 0 && confirm.height > 0) + assertTrue(position.y >= 0 && position.y + confirm.height <= viewport.worldHeight) + confirm.listeners.filterIsInstance().forEach { it.clicked(InputEvent(), 0f, 0f) } + val intents = mutableListOf() + bus.drainIntents { intents += it } + assertTrue(intents.contains(MenuIntent.ConfirmOverwrite("profile", "manual"))) + assertTrue(stage.keyUp(Input.Keys.ESCAPE)) + bus.drainIntents { intents += it } + assertEquals(MenuIntent.CancelOverwrite, intents.last()) + bus.postState(bus.latestState().copy(savedGames = SavedGamesState("other"))) + stage.act(0f) + assertTrue(overwrite.isDisabled) + assertTrue(labels(stage.root).contains("No saved games for this profile.")) + } finally { + stage.dispose() + } + } + private fun descendants(actor: Actor): List = listOf(actor) + if (actor is Group) actor.children.flatMap(::descendants) else emptyList() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index d210862a..a782321d 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -5,11 +5,134 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.CvarTypes import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.demoth.cake.hosting.* import org.junit.jupiter.api.Test /** Checks menu intent navigation and deferred option edits using a local cvar registry. */ class MenuControllerTest { + private fun savedEntry(slot: String, reason: String? = null) = + HostSaveEntry(slot, "Checkpoint", "base1", 1000L, HostMode.SINGLEPLAYER, reason) + + @Test + fun saveCatalogRefreshesOnEntryAndProfileChangeWithoutPerFrameIO() { + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("manual")) } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + bus.postIntent(MenuIntent.OpenLoadGame) + controller.pumpIntents() + repeat(3) { controller.refreshExternalState() } + assertEquals(1, backend.saveListReads) + assertEquals("manual", bus.latestState().savedGames.selectedSlot) + backend.applyProfile("other") + backend.saves = emptyList() + controller.refreshExternalState() + assertEquals(2, backend.saveListReads) + assertEquals("other", bus.latestState().savedGames.profileId) + assertTrue(bus.latestState().savedGames.entries.isEmpty()) + bus.postIntent(MenuIntent.LoadGameRequested("default", "manual")) + controller.pumpIntents() + assertTrue(backend.loads.isEmpty()) + } + + @Test + fun saveRequiresConfirmationAndRejectsStaleProfileAndAutosaveRequests() { + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("save0"), savedEntry("manual")) } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + fun post(intent: MenuIntent) { bus.postIntent(intent); controller.pumpIntents() } + post(MenuIntent.OpenSaveGame) + post(MenuIntent.SaveGameRequested("default", "save0")) + assertNull(bus.latestState().savedGames.pendingOverwriteSlot) + post(MenuIntent.SaveGameRequested("default", "manual")) + assertEquals("manual", bus.latestState().savedGames.pendingOverwriteSlot) + assertTrue(backend.savesWritten.isEmpty()) + post(MenuIntent.CancelOverwrite) + post(MenuIntent.ConfirmOverwrite("default", "manual")) + assertTrue(backend.savesWritten.isEmpty()) + post(MenuIntent.SaveGameRequested("default", "manual")) + post(MenuIntent.ConfirmOverwrite("default", "manual")) + assertEquals(listOf("manual" to true), backend.savesWritten) + assertEquals("Game saved.", bus.latestState().savedGames.statusMessage) + post(MenuIntent.SaveGameRequested("default", "manual")) + backend.applyProfile("other") + post(MenuIntent.ConfirmOverwrite("default", "manual")) + assertEquals(1, backend.savesWritten.size) + } + + @Test + fun saveEligibilityIsRecheckedBeforePendingOverwriteAndNewSave() { + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("manual")) } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + bus.postIntent(MenuIntent.OpenSaveGame) + bus.postIntent(MenuIntent.SaveGameRequested("default", "manual")) + controller.pumpIntents() + backend.access = HostSaveAccess(null, "Cannot save while dead") + bus.postIntent(MenuIntent.ConfirmOverwrite("default", "manual")) + controller.pumpIntents() + assertEquals("Cannot save while dead", bus.latestState().savedGames.statusMessage) + bus.postIntent(MenuIntent.SaveGameRequested("default")) + controller.pumpIntents() + assertTrue(backend.savesWritten.isEmpty()) + assertNull(bus.latestState().savedGames.pendingOverwriteSlot) + } + + @Test + fun loadFailureStaysVisibleAndSuccessClosesMenuOnlyAfterBackendCompletes() { + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("manual"), savedEntry("corrupt", "Invalid save")) } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + fun post(intent: MenuIntent): List { + bus.drainSignals { } + bus.postIntent(intent) + controller.pumpIntents() + return mutableListOf().also { result -> bus.drainSignals { result += it } } + } + assertTrue(post(MenuIntent.OpenLoadGame).contains(MenuSignal.ShowMenu)) + post(MenuIntent.LoadGameRequested("default", "corrupt")) + assertTrue(backend.loads.isEmpty()) + assertEquals("Invalid save", bus.latestState().savedGames.statusMessage) + backend.loadOutcome = HostSaveResult.Failed("Restore failed", java.io.IOException("disk failure")) + assertFalse(post(MenuIntent.LoadGameRequested("default", "manual")).contains(MenuSignal.CloseMenu)) + assertEquals(MenuScreen.LOAD_GAME, bus.latestState().activeScreen) + assertEquals("Load failed: Restore failed", bus.latestState().savedGames.statusMessage) + backend.loadOutcome = HostSaveResult.Completed("manual") + assertTrue(post(MenuIntent.LoadGameRequested("default", "manual")).contains(MenuSignal.CloseMenu)) + assertEquals(listOf("manual", "manual"), backend.loads) + } + + @Test + fun saveListFailureAndEmptyCatalogAreDistinctAndRepeatedOpenClearsConfirmation() { + val backend = FakeMenuBackend() + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + bus.postIntent(MenuIntent.OpenLoadGame) + controller.pumpIntents() + assertTrue(bus.latestState().savedGames.entries.isEmpty()) + assertEquals("", bus.latestState().savedGames.statusMessage) + backend.catalogFailure = HostSaveCatalogResult.Failed("disk unavailable", java.io.IOException()) + bus.postIntent(MenuIntent.RefreshSavedGames) + controller.pumpIntents() + assertEquals("Could not list saved games: disk unavailable", bus.latestState().savedGames.statusMessage) + backend.catalogFailure = null + backend.saves = listOf(savedEntry("manual")) + bus.postIntent(MenuIntent.OpenSaveGame) + bus.postIntent(MenuIntent.SaveGameRequested("default", "manual")) + controller.pumpIntents() + assertEquals("manual", bus.latestState().savedGames.pendingOverwriteSlot) + bus.postIntent(MenuIntent.OpenSaveGame) + controller.pumpIntents() + assertNull(bus.latestState().savedGames.pendingOverwriteSlot) + } + @Test fun hostingMapsRefreshOnEntryAndInvalidationButNotEveryFrame() { val backend = FakeMenuBackend() @@ -447,6 +570,26 @@ class MenuControllerTest { } private class FakeMenuBackend : MenuBackend { + var access = HostSaveAccess(null, null) + var saves = emptyList() + var saveListReads = 0 + var catalogFailure: HostSaveCatalogResult? = null + var loadOutcome: HostSaveResult = HostSaveResult.Completed("manual") + val loads = mutableListOf() + val savesWritten = mutableListOf>() + override fun saveAccess() = access + override fun listSaves(): HostSaveCatalogResult { + saveListReads++ + return catalogFailure ?: HostSaveCatalogResult.Available(activeProfileId(), saves) + } + override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { + savesWritten += slot to overwrite + return HostSaveResult.Completed(slot ?: "new") + } + override fun loadGame(slot: String): HostSaveResult { + loads += slot + return loadOutcome + } var maps = emptyList() var mapReads = 0 override fun hostMaps(): List { From 59f4810b92ee79256a47bd0fbb0c040ccbf2f69e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:45:58 +0100 Subject: [PATCH 09/57] Group new game and save/load under Singleplayer menu --- cake/README.md | 5 +- .../kotlin/jake2/server/CakeNewGameGlTest.kt | 2 +- .../demoth/cake/stages/HostingSetupStage.kt | 4 +- .../org/demoth/cake/stages/MainMenuStage.kt | 25 ++-------- .../org/demoth/cake/stages/MenuStages.kt | 3 +- .../org/demoth/cake/stages/SavedGamesStage.kt | 2 +- .../cake/stages/SingleplayerMenuStage.kt | 47 +++++++++++++++++++ .../org/demoth/cake/ui/menu/MenuController.kt | 22 +++++---- .../org/demoth/cake/ui/menu/MenuIntent.kt | 1 + .../org/demoth/cake/ui/menu/MenuState.kt | 1 + .../org/demoth/cake/ui/MenuStagesGlTest.kt | 47 +++++++++++++++++++ .../demoth/cake/ui/menu/MenuControllerTest.kt | 24 ++++++++++ 12 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt diff --git a/cake/README.md b/cake/README.md index 6e2ff929..83cd4160 100644 --- a/cake/README.md +++ b/cake/README.md @@ -133,7 +133,10 @@ The Host Game menu configures these settings for you. Saves are stored in `~/.cake//save/`. Console `load ` restores a game even while disconnected. `save ` requires an active game. -Fullgame's main menu provides Load Game and Save Game for singleplayer. New Save +Fullgame's Singleplayer submenu contains New Game, Load Game and Save Game. +New Game opens difficulty selection. The submenu stays accessible during play, +with each action enabled when eligible; Back from these screens returns there. +New Save creates a checkpoint; Overwrite asks before replacing a manual save. Autosave is listed for loading and reserved against manual overwrite. Save requires a living, spawned local player. Loading is available from startup and during singleplayer; diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt index 1896e236..6d6854fc 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt @@ -83,7 +83,7 @@ class CakeNewGameGlTest { cake.render() // The queued HostingFailed intent reaches the menu. assertEquals(HostState.STOPPED, host.state) assertEquals("DISCONNECTED", state()) - assertEquals(MenuScreen.SINGLEPLAYER, bus.latestState().activeScreen) + assertEquals(MenuScreen.NEW_GAME, bus.latestState().activeScreen) assertTrue(bus.latestState().singleplayer.statusMessage.contains("did not start a map")) assertEquals(true, field("menuVisible")) assertSame(manager, host.server) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt index 5a597fa5..0e84946a 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt @@ -15,7 +15,7 @@ class HostingSetupStage( style: GameUiStyle, private val singleplayer: Boolean, ) : BackNavigableMenuStage(viewport, menuEventBus, - if (singleplayer) MenuIntent.OpenMainMenu else MenuIntent.OpenMultiplayerMenu) { + if (singleplayer) MenuIntent.OpenSingleplayerMenu else MenuIntent.OpenMultiplayerMenu) { private val initial = (if (singleplayer) menuEventBus.latestState().singleplayer else menuEventBus.latestState().hostGame).form private val map = SelectBox(style.menuWidgets.selectBox).apply { maxListCount = 12 @@ -40,7 +40,7 @@ class HostingSetupStage( init { val root = Table().apply { defaults().left().pad(8f) - add(Label(if (singleplayer) "Singleplayer" else "Host Game", style.menuWidgets.label)).colspan(2).row() + add(Label(if (singleplayer) "New Game" else "Host Game", style.menuWidgets.label)).colspan(2).row() if (!singleplayer) { add(Label("Map", style.menuWidgets.label)); add(map).width(360f).row() add(Label("Mode", style.menuWidgets.label)); add(mode).growX().row() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt index 1deda330..0c2235c6 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt @@ -3,7 +3,6 @@ package org.demoth.cake.stages import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.TextButton -import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.exec.Cbuf import org.demoth.cake.BuildVersion @@ -40,9 +39,6 @@ class MainMenuStage( ) { private var currentProfileLabel: Label private var switchProfileButton: TextButton - private var singleplayerButton: TextButton? = null - private var loadButton: TextButton? = null - private var saveButton: TextButton? = null private var disconnectButton: TextButton private var renderedProfileId: String = "" private var lastCanDisconnectState: Boolean = false @@ -70,21 +66,9 @@ class MainMenuStage( row() if (showHosting) { - singleplayerButton = createMenuButton("Singleplayer", buttonStyle) { - menuEventBus.postIntent(MenuIntent.OpenHostingSetup(true)) - } - add(singleplayerButton) - row() - loadButton = createMenuButton("Load Game", buttonStyle) { - menuEventBus.postIntent(MenuIntent.OpenLoadGame) - }.apply { labelCell.minWidth(0f) } - saveButton = createMenuButton("Save Game", buttonStyle) { - menuEventBus.postIntent(MenuIntent.OpenSaveGame) - }.apply { labelCell.minWidth(0f) } - add(Table().apply { - add(loadButton).growX().padRight(4f) - add(saveButton).growX().padLeft(4f) - }) + add(createMenuButton("Singleplayer", buttonStyle) { + menuEventBus.postIntent(MenuIntent.OpenSingleplayerMenu) + }.apply { name = "singleplayer-menu" }) row() } @@ -118,9 +102,6 @@ class MainMenuStage( override fun act(delta: Float) { super.act(delta) val snapshot = menuEventBus.latestState() - singleplayerButton?.isDisabled = !snapshot.canHostGame - loadButton?.isDisabled = snapshot.saveAccess.loadRejectionReason != null - saveButton?.isDisabled = snapshot.saveAccess.saveRejectionReason != null val state = snapshot.mainMenu val active = state.activeProfileId.trim() if (active != renderedProfileId) { 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 7ad7216c..f2e03d38 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 @@ -52,7 +52,8 @@ class MenuStages( MenuScreen.SAVE_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = true) MenuScreen.PROFILE_EDIT -> ProfileEditStage(viewport, menuEventBus, style) MenuScreen.MULTIPLAYER -> MultiplayerMenuStage(viewport, menuEventBus, style, showHosting) - MenuScreen.SINGLEPLAYER -> HostingSetupStage(viewport, menuEventBus, style, true) + MenuScreen.SINGLEPLAYER -> SingleplayerMenuStage(viewport, menuEventBus, style) + MenuScreen.NEW_GAME -> HostingSetupStage(viewport, menuEventBus, style, true) MenuScreen.HOST_GAME -> HostingSetupStage(viewport, menuEventBus, style, false) MenuScreen.JOIN_GAME -> JoinGameStage(viewport, menuEventBus, style) MenuScreen.PLAYER_SETUP -> PlayerSetupStage(viewport, menuEventBus, style, createPreviewAssets()) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index cc893703..25d240b0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -20,7 +20,7 @@ class SavedGamesStage( menuEventBus: MenuEventBus, style: GameUiStyle, private val saving: Boolean, -) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { +) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenSingleplayerMenu) { private data class Choice(val entry: HostSaveEntry) { override fun toString(): String { val date = entry.savedAtMillis?.let { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt new file mode 100644 index 00000000..eec7d28a --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt @@ -0,0 +1,47 @@ +package org.demoth.cake.stages + +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.utils.viewport.Viewport +import org.demoth.cake.ui.GameUiStyle +import org.demoth.cake.ui.menu.MenuEventBus +import org.demoth.cake.ui.menu.MenuIntent + +/** Navigation stays accessible while playing; eligibility applies to individual actions. */ +class SingleplayerMenuStage( + viewport: Viewport, + menuEventBus: MenuEventBus, + style: GameUiStyle, +) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { + private val newGame = createMenuButton("New Game", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.OpenHostingSetup(true)) + }.apply { name = "new-game-menu" } + private val load = createMenuButton("Load Game", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.OpenLoadGame) + }.apply { name = "load-game-menu" } + private val save = createMenuButton("Save Game", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.OpenSaveGame) + }.apply { name = "save-game-menu" } + private val resume = createMenuButton("Resume Game", style.menuWidgets.button) { + menuEventBus.postIntent(MenuIntent.ResumeGame) + } + + init { + addActor(createHubMenuTable().apply { + add(Label("Singleplayer", style.menuWidgets.label)).row() + add(newGame).row() + add(load).row() + add(save).row() + add(resume).row() + add(createBackButton(style = style.menuWidgets.button)).row() + }) + } + + override fun act(delta: Float) { + super.act(delta) + val state = menuEventBus.latestState() + newGame.isDisabled = !state.canHostGame + load.isDisabled = state.saveAccess.loadRejectionReason != null + save.isDisabled = state.saveAccess.saveRejectionReason != null + resume.isDisabled = !state.canResumeGame + } +} 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 26c6eea1..a15696bf 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 @@ -87,10 +87,10 @@ class MenuController( private fun handleIntent(intent: MenuIntent) { when (intent) { + MenuIntent.OpenSingleplayerMenu -> refreshState(activeScreen = MenuScreen.SINGLEPLAYER) MenuIntent.OpenLoadGame, MenuIntent.OpenSaveGame -> { - state = state.copy(activeScreen = if (intent == MenuIntent.OpenLoadGame) MenuScreen.LOAD_GAME else MenuScreen.SAVE_GAME) - refreshSavedGames() - refreshState() + refreshSavedGames(saving = intent == MenuIntent.OpenSaveGame) + refreshState(activeScreen = if (intent == MenuIntent.OpenLoadGame) MenuScreen.LOAD_GAME else MenuScreen.SAVE_GAME) bus.postSignal(MenuSignal.ShowMenu) } MenuIntent.RefreshSavedGames -> if (isSaveScreen()) { @@ -136,7 +136,7 @@ class MenuController( if (!intent.singleplayer) refreshHostingMaps() state = if (intent.singleplayer) state.copy(singleplayer = state.singleplayer.copy(statusMessage = "")) else state.copy(hostGame = state.hostGame.copy(statusMessage = "")) - refreshState(activeScreen = if (intent.singleplayer) MenuScreen.SINGLEPLAYER else MenuScreen.HOST_GAME) + refreshState(activeScreen = if (intent.singleplayer) MenuScreen.NEW_GAME else MenuScreen.HOST_GAME) } is MenuIntent.UpdateHostingDraft -> { val setup = (if (intent.singleplayer) state.singleplayer else state.hostGame).copy(form = intent.form) @@ -150,7 +150,7 @@ class MenuController( refreshState() } is MenuIntent.HostingFailed -> { - state = state.copy(activeScreen = MenuScreen.SINGLEPLAYER, + state = state.copy(activeScreen = MenuScreen.NEW_GAME, singleplayer = state.singleplayer.copy(statusMessage = intent.reason)) refreshState() } @@ -412,7 +412,10 @@ class MenuController( private fun isCurrentSaveProfile(profileId: String): Boolean = profileId == backend.activeProfileId() && profileId == state.savedGames.profileId - private fun refreshSavedGames(selectedSlot: String? = state.savedGames.selectedSlot) { + private fun refreshSavedGames( + selectedSlot: String? = state.savedGames.selectedSlot, + saving: Boolean = state.activeScreen == MenuScreen.SAVE_GAME, + ) { val profileId = backend.activeProfileId() val catalog = backend.listSaves() val entries = (catalog as? HostSaveCatalogResult.Available)?.takeIf { it.profileId == profileId }?.entries.orEmpty() @@ -422,7 +425,7 @@ class MenuController( is HostSaveCatalogResult.Failed -> "Could not list saved games: ${catalog.reason}" } val selected = selectedSlot?.takeIf { state.savedGames.profileId == profileId && entries.any { entry -> entry.slot == it } } - ?: entries.firstOrNull { if (state.activeScreen == MenuScreen.SAVE_GAME) !it.autosave else it.loadRejectionReason == null }?.slot + ?: entries.firstOrNull { if (saving) !it.autosave else it.loadRejectionReason == null }?.slot ?: entries.firstOrNull()?.slot state = state.copy(savedGames = SavedGamesState(profileId, entries, selected, statusMessage = status)) } @@ -554,8 +557,9 @@ class MenuController( return when (screen) { MenuScreen.MAIN -> 0 MenuScreen.PROFILE_EDIT, MenuScreen.MULTIPLAYER, MenuScreen.OPTIONS, MenuScreen.SINGLEPLAYER, - MenuScreen.CONTENT_BROWSER, MenuScreen.LOAD_GAME, MenuScreen.SAVE_GAME -> 1 - MenuScreen.JOIN_GAME, MenuScreen.PLAYER_SETUP, MenuScreen.OPTIONS_SECTION, MenuScreen.HOST_GAME -> 2 + MenuScreen.CONTENT_BROWSER -> 1 + MenuScreen.JOIN_GAME, MenuScreen.PLAYER_SETUP, MenuScreen.OPTIONS_SECTION, MenuScreen.HOST_GAME, + MenuScreen.NEW_GAME, MenuScreen.LOAD_GAME, MenuScreen.SAVE_GAME -> 2 } } 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 e111c404..88ccd246 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 @@ -10,6 +10,7 @@ sealed interface MenuIntent { data object RequestStateSync : MenuIntent data object OpenProfileEditor : MenuIntent data object OpenMainMenu : MenuIntent + data object OpenSingleplayerMenu : MenuIntent data object OpenLoadGame : MenuIntent data object OpenSaveGame : MenuIntent data object RefreshSavedGames : MenuIntent diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt index 7a39a159..15ef0f34 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt @@ -10,6 +10,7 @@ enum class MenuScreen { PROFILE_EDIT, MULTIPLAYER, SINGLEPLAYER, + NEW_GAME, HOST_GAME, JOIN_GAME, PLAYER_SETUP, 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 0b043668..b56fc90e 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 @@ -16,6 +16,8 @@ import com.badlogic.gdx.scenes.scene2d.InputEvent import com.badlogic.gdx.scenes.scene2d.utils.ClickListener import org.demoth.cake.stages.HostingSetupStage import org.demoth.cake.stages.SavedGamesStage +import org.demoth.cake.stages.SingleplayerMenuStage +import org.demoth.cake.stages.MainMenuStage import org.demoth.cake.hosting.HostSaveAccess import org.demoth.cake.hosting.HostSaveEntry import org.demoth.cake.hosting.HostMode @@ -71,6 +73,7 @@ class MenuStagesGlTest { try { verifyHostMapSelection(viewport, style) verifySavedGames(viewport, style) + verifySingleplayerHub(viewport, style) menus.rebuild(style) val main = menus.inputProcessor assertFalse(menus.select(MenuScreen.MAIN)) @@ -185,11 +188,55 @@ class MenuStagesGlTest { stage.act(0f) assertTrue(overwrite.isDisabled) assertTrue(labels(stage.root).contains("No saved games for this profile.")) + stage.keyUp(Input.Keys.ESCAPE) + bus.drainIntents { intents += it } + assertEquals(MenuIntent.OpenSingleplayerMenu, intents.last()) } finally { stage.dispose() } } + private fun verifySingleplayerHub(viewport: UiViewport, style: GameUiStyle) { + val bus = MenuEventBus(MenuStateSnapshot(canHostGame = false, canResumeGame = true, + saveAccess = HostSaveAccess(null, null))) + val main = MainMenuStage(viewport, bus, style, showHosting = true) + val hub = SingleplayerMenuStage(viewport, bus, style) + val difficulty = HostingSetupStage(viewport, bus, style, true) + val load = SavedGamesStage(viewport, bus, style, saving = false) + try { + main.act(0f) + assertFalse(main.root.findActor("singleplayer-menu").isDisabled) + assertFalse(labels(main.root).any { it == "Load Game" || it == "Save Game" }) + hub.act(0f) + hub.draw() + assertTrue(hub.root.findActor("new-game-menu").isDisabled) + assertFalse(hub.root.findActor("load-game-menu").isDisabled) + assertFalse(hub.root.findActor("save-game-menu").isDisabled) + bus.postState(bus.latestState().copy(canHostGame = true, canResumeGame = false, + saveAccess = HostSaveAccess(null, "Start a game first"))) + hub.act(0f) + assertFalse(hub.root.findActor("new-game-menu").isDisabled) + assertFalse(hub.root.findActor("load-game-menu").isDisabled) + assertTrue(hub.root.findActor("save-game-menu").isDisabled) + val button = hub.root.findActor("new-game-menu") + bus.drainIntents { } + button.listeners.filterIsInstance().forEach { it.clicked(InputEvent(), 0f, 0f) } + val intents = mutableListOf() + bus.drainIntents { intents += it } + assertEquals(listOf(MenuIntent.OpenHostingSetup(true)), intents) + for (stage in listOf(difficulty, load)) { + stage.keyUp(Input.Keys.ESCAPE) + bus.drainIntents { intents += it } + assertEquals(MenuIntent.OpenSingleplayerMenu, intents.last()) + } + hub.keyUp(Input.Keys.ESCAPE) + bus.drainIntents { intents += it } + assertEquals(MenuIntent.OpenMainMenu, intents.last()) + } finally { + listOf(main, hub, difficulty, load).forEach { it.dispose() } + } + } + private fun descendants(actor: Actor): List = listOf(actor) + if (actor is Group) actor.children.flatMap(::descendants) else emptyList() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index a782321d..2ec44499 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -13,6 +13,30 @@ import org.junit.jupiter.api.Test /** Checks menu intent navigation and deferred option edits using a local cvar registry. */ class MenuControllerTest { + @Test + fun singleplayerHubOwnsNewGameLoadAndSaveNavigation() { + val bus = MenuEventBus() + val controller = MenuController(FakeMenuBackend(), bus) + controller.initialize() + bus.drainSignals { } + fun open(intent: MenuIntent, screen: MenuScreen, sound: MenuUiSoundEffect) { + bus.postIntent(intent) + controller.pumpIntents() + assertEquals(screen, bus.latestState().activeScreen) + assertEquals(listOf(sound), drainedSoundEffects(bus)) + } + open(MenuIntent.OpenSingleplayerMenu, MenuScreen.SINGLEPLAYER, MenuUiSoundEffect.ENTER_SUBMENU) + open(MenuIntent.OpenHostingSetup(true), MenuScreen.NEW_GAME, MenuUiSoundEffect.ENTER_SUBMENU) + open(MenuIntent.OpenSingleplayerMenu, MenuScreen.SINGLEPLAYER, MenuUiSoundEffect.EXIT_SUBMENU) + open(MenuIntent.OpenLoadGame, MenuScreen.LOAD_GAME, MenuUiSoundEffect.ENTER_SUBMENU) + open(MenuIntent.OpenSingleplayerMenu, MenuScreen.SINGLEPLAYER, MenuUiSoundEffect.EXIT_SUBMENU) + open(MenuIntent.OpenSaveGame, MenuScreen.SAVE_GAME, MenuUiSoundEffect.ENTER_SUBMENU) + bus.postIntent(MenuIntent.HostingFailed("Campaign could not start")) + controller.pumpIntents() + assertEquals(MenuScreen.NEW_GAME, bus.latestState().activeScreen) + assertEquals("Campaign could not start", bus.latestState().singleplayer.statusMessage) + } + private fun savedEntry(slot: String, reason: String? = null) = HostSaveEntry(slot, "Checkpoint", "base1", 1000L, HostMode.SINGLEPLAYER, reason) From f1538c9c9a684ce65aefe7169ce0595f2a7cdf6a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:48:08 +0100 Subject: [PATCH 10/57] Use Escape navigation instead of redundant Resume buttons --- cake/README.md | 1 + cake/core/src/main/kotlin/org/demoth/cake/Cake.kt | 1 - .../main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt | 6 +----- .../kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt | 5 ----- .../main/kotlin/org/demoth/cake/ui/menu/MenuController.kt | 3 --- .../src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt | 1 - .../src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt | 1 - .../src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt | 6 +++--- 8 files changed, 5 insertions(+), 19 deletions(-) diff --git a/cake/README.md b/cake/README.md index 83cd4160..019cbdab 100644 --- a/cake/README.md +++ b/cake/README.md @@ -136,6 +136,7 @@ game even while disconnected. `save ` requires an active game. Fullgame's Singleplayer submenu contains New Game, Load Game and Save Game. New Game opens difficulty selection. The submenu stays accessible during play, with each action enabled when eligible; Back from these screens returns there. +Escape goes back through the menus and returns to gameplay from the main menu. New Save creates a checkpoint; Overwrite asks before replacing a manual save. Autosave is listed for loading and reserved against manual overwrite. Save requires a living, 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 c63f0736..e6596768 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1590,7 +1590,6 @@ class Cake( private fun createMenuBackend(): MenuBackend = object : MenuBackend { override fun saveAccess(): HostSaveAccess = host.saveAccess() override fun listSaves(): HostSaveCatalogResult = host.listSaves() - override fun canResumeGame(): Boolean = networkState == ACTIVE && game3dScreen != null override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = finishSaveOperation(host.saveGame(slot, overwrite)) override fun loadGame(slot: String): HostSaveResult = finishSaveOperation(host.loadGame(slot)) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index 25d240b0..eb83380b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -55,9 +55,6 @@ class SavedGamesStage( menuEventBus.postIntent(MenuIntent.CancelOverwrite) }.apply { name = "cancel-overwrite" }).pad(4f) } - private val resume = createMenuButton("Resume Game", style.menuWidgets.button) { - menuEventBus.postIntent(MenuIntent.ResumeGame) - } init { slots.addListener(object : ChangeListener() { @@ -80,7 +77,7 @@ class SavedGamesStage( row() add(confirmation).colspan(2).row() add(status).colspan(2).width(560f).row() - add(resume); add(createBackButton(style = style.menuWidgets.button)).row() + add(createBackButton(style = style.menuWidgets.button)).colspan(2).row() } addCenteredForm(root, style.menuWidgets.scrollPane) } @@ -106,7 +103,6 @@ class SavedGamesStage( overwrite.isDisabled = pending || reason != null || selected == null || selected.autosave confirmation.isVisible = pending confirmation.cells.forEach { it.height(if (pending) 68f else 0f) } - resume.isDisabled = !snapshot.canResumeGame detail.setText(when { state.entries.isEmpty() -> "No saved games for this profile." saving && selected?.autosave == true -> "Autosave is reserved. Choose New Save or another slot." diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt index eec7d28a..0d5c7be9 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt @@ -21,9 +21,6 @@ class SingleplayerMenuStage( private val save = createMenuButton("Save Game", style.menuWidgets.button) { menuEventBus.postIntent(MenuIntent.OpenSaveGame) }.apply { name = "save-game-menu" } - private val resume = createMenuButton("Resume Game", style.menuWidgets.button) { - menuEventBus.postIntent(MenuIntent.ResumeGame) - } init { addActor(createHubMenuTable().apply { @@ -31,7 +28,6 @@ class SingleplayerMenuStage( add(newGame).row() add(load).row() add(save).row() - add(resume).row() add(createBackButton(style = style.menuWidgets.button)).row() }) } @@ -42,6 +38,5 @@ class SingleplayerMenuStage( newGame.isDisabled = !state.canHostGame load.isDisabled = state.saveAccess.loadRejectionReason != null save.isDisabled = state.saveAccess.saveRejectionReason != null - resume.isDisabled = !state.canResumeGame } } 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 a15696bf..563e61cc 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 @@ -9,7 +9,6 @@ interface MenuBackend { fun listSaves(): HostSaveCatalogResult = HostSaveCatalogResult.Rejected("Save/load is unavailable.") fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = HostSaveResult.Rejected("Saving is unavailable.") fun loadGame(slot: String): HostSaveResult = HostSaveResult.Rejected("Loading is unavailable.") - fun canResumeGame(): Boolean = false fun hostMaps(): List = emptyList() fun hostingUnavailableReason(): String? = "Hosting requires the Cake fullgame application." fun startHosting(singleplayer: Boolean, form: HostGameFormState): String = @@ -127,7 +126,6 @@ class MenuController( if (reason != null) saveStatus(reason) else handleSaveResult(backend.saveGame(intent.slot, overwrite = true), loading = false) } MenuIntent.CancelOverwrite -> saveStatus("") - MenuIntent.ResumeGame -> if (backend.canResumeGame()) bus.postSignal(MenuSignal.CloseMenu) is MenuIntent.OpenUiSample -> bus.postSignal(MenuSignal.OpenUiSample(intent.game)) MenuIntent.CloseUiSample -> bus.postSignal(MenuSignal.CloseUiSample) is MenuIntent.CaptureUiSample -> @@ -526,7 +524,6 @@ class MenuController( state = MenuStateSnapshot( activeScreen = activeScreen, canHostGame = backend.hostingUnavailableReason() == null, - canResumeGame = backend.canResumeGame(), saveAccess = backend.saveAccess(), savedGames = state.savedGames, singleplayer = state.singleplayer, 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 88ccd246..88567810 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 @@ -19,7 +19,6 @@ sealed interface MenuIntent { data class SaveGameRequested(val profileId: String, val slot: String? = null) : MenuIntent data class ConfirmOverwrite(val profileId: String, val slot: String) : MenuIntent data object CancelOverwrite : MenuIntent - data object ResumeGame : MenuIntent data object OpenMultiplayerMenu : MenuIntent data class OpenHostingSetup(val singleplayer: Boolean) : MenuIntent data class StartHosting(val singleplayer: Boolean, val form: HostGameFormState) : MenuIntent diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt index 15ef0f34..acbf3fe7 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt @@ -79,7 +79,6 @@ data class OptionsSectionState( data class MenuStateSnapshot( val activeScreen: MenuScreen = MenuScreen.MAIN, val canHostGame: Boolean = false, - val canResumeGame: Boolean = false, val saveAccess: HostSaveAccess = HostSaveAccess("Save/load is unavailable.", "Save/load is unavailable."), val savedGames: SavedGamesState = SavedGamesState(), val singleplayer: HostingSetupState = HostingSetupState(), 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 b56fc90e..267ef08f 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 @@ -157,7 +157,7 @@ class MenuStagesGlTest { HostSaveEntry("manual", "Checkpoint", "base2", 2000L, HostMode.SINGLEPLAYER, null), ) val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, - canResumeGame = true, saveAccess = HostSaveAccess(null, null), + saveAccess = HostSaveAccess(null, null), savedGames = SavedGamesState("profile", entries, "save0"))) val stage = SavedGamesStage(viewport, bus, style, saving = true) try { @@ -197,7 +197,7 @@ class MenuStagesGlTest { } private fun verifySingleplayerHub(viewport: UiViewport, style: GameUiStyle) { - val bus = MenuEventBus(MenuStateSnapshot(canHostGame = false, canResumeGame = true, + val bus = MenuEventBus(MenuStateSnapshot(canHostGame = false, saveAccess = HostSaveAccess(null, null))) val main = MainMenuStage(viewport, bus, style, showHosting = true) val hub = SingleplayerMenuStage(viewport, bus, style) @@ -212,7 +212,7 @@ class MenuStagesGlTest { assertTrue(hub.root.findActor("new-game-menu").isDisabled) assertFalse(hub.root.findActor("load-game-menu").isDisabled) assertFalse(hub.root.findActor("save-game-menu").isDisabled) - bus.postState(bus.latestState().copy(canHostGame = true, canResumeGame = false, + bus.postState(bus.latestState().copy(canHostGame = true, saveAccess = HostSaveAccess(null, "Start a game first"))) hub.act(0f) assertFalse(hub.root.findActor("new-game-menu").isDisabled) From b88a2ac6fb31b1fce8820d96e8b0e31d42c306e4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 15:51:16 +0100 Subject: [PATCH 11/57] Refresh saved games on entry without a manual Refresh button --- cake/README.md | 1 + .../main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt | 6 ++---- .../main/kotlin/org/demoth/cake/ui/menu/MenuController.kt | 6 +----- .../src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt | 1 - .../kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt | 2 +- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/cake/README.md b/cake/README.md index 019cbdab..bf7c92d6 100644 --- a/cake/README.md +++ b/cake/README.md @@ -137,6 +137,7 @@ Fullgame's Singleplayer submenu contains New Game, Load Game and Save Game. New Game opens difficulty selection. The submenu stays accessible during play, with each action enabled when eligible; Back from these screens returns there. Escape goes back through the menus and returns to gameplay from the main menu. +Saved games refresh automatically whenever Load Game or Save Game is opened. New Save creates a checkpoint; Overwrite asks before replacing a manual save. Autosave is listed for loading and reserved against manual overwrite. Save requires a living, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index eb83380b..bb833ee1 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -70,10 +70,8 @@ class SavedGamesStage( add(profile).colspan(2).row() add(slots).colspan(2).width(560f).row() add(detail).colspan(2).width(560f).row() - add(primary) - if (saving) add(overwrite) else add(createMenuButton("Refresh", style.menuWidgets.button) { - menuEventBus.postIntent(MenuIntent.RefreshSavedGames) - }) + add(primary).colspan(if (saving) 1 else 2) + if (saving) add(overwrite) row() add(confirmation).colspan(2).row() add(status).colspan(2).width(560f).row() 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 563e61cc..900cce29 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 @@ -92,10 +92,6 @@ class MenuController( refreshState(activeScreen = if (intent == MenuIntent.OpenLoadGame) MenuScreen.LOAD_GAME else MenuScreen.SAVE_GAME) bus.postSignal(MenuSignal.ShowMenu) } - MenuIntent.RefreshSavedGames -> if (isSaveScreen()) { - refreshSavedGames() - refreshState() - } is MenuIntent.SelectSaveSlot -> if (isCurrentSaveProfile(intent.profileId) && state.savedGames.entries.any { it.slot == intent.slot }) { state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, @@ -418,7 +414,7 @@ class MenuController( val catalog = backend.listSaves() val entries = (catalog as? HostSaveCatalogResult.Available)?.takeIf { it.profileId == profileId }?.entries.orEmpty() val status = when (catalog) { - is HostSaveCatalogResult.Available -> if (catalog.profileId == profileId) "" else "The selected profile changed. Refresh saved games." + is HostSaveCatalogResult.Available -> if (catalog.profileId == profileId) "" else "The selected profile changed. Reopen this menu." is HostSaveCatalogResult.Rejected -> catalog.reason is HostSaveCatalogResult.Failed -> "Could not list saved games: ${catalog.reason}" } 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 88567810..280069b9 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 @@ -13,7 +13,6 @@ sealed interface MenuIntent { data object OpenSingleplayerMenu : MenuIntent data object OpenLoadGame : MenuIntent data object OpenSaveGame : MenuIntent - data object RefreshSavedGames : MenuIntent data class SelectSaveSlot(val profileId: String, val slot: String) : MenuIntent data class LoadGameRequested(val profileId: String, val slot: String) : MenuIntent data class SaveGameRequested(val profileId: String, val slot: String? = null) : MenuIntent diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index 2ec44499..44ab0f55 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -143,7 +143,7 @@ class MenuControllerTest { assertTrue(bus.latestState().savedGames.entries.isEmpty()) assertEquals("", bus.latestState().savedGames.statusMessage) backend.catalogFailure = HostSaveCatalogResult.Failed("disk unavailable", java.io.IOException()) - bus.postIntent(MenuIntent.RefreshSavedGames) + bus.postIntent(MenuIntent.OpenLoadGame) controller.pumpIntents() assertEquals("Could not list saved games: disk unavailable", bus.latestState().savedGames.statusMessage) backend.catalogFailure = null From bff0e5c23e64af8a2b56cee19100255c30db9d93 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 16:08:18 +0100 Subject: [PATCH 12/57] Add save list and preview layout with confirmed checkpoint deletion --- cake/README.md | 6 +- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 18 ++- .../jake2/server/CakeSaveMenusGlTest.kt | 29 +++- .../jake2/server/EmbeddedCakeSavesTest.kt | 37 +++++ .../cake/fullgame/HostAvailabilityTest.kt | 1 + .../src/main/kotlin/org/demoth/cake/Cake.kt | 1 + .../org/demoth/cake/hosting/CakeHost.kt | 3 + .../kotlin/org/demoth/cake/stages/README.md | 2 + .../org/demoth/cake/stages/SavedGamesStage.kt | 126 +++++++++++++----- .../org/demoth/cake/ui/menu/MenuController.kt | 37 ++++- .../org/demoth/cake/ui/menu/MenuIntent.kt | 4 +- .../org/demoth/cake/ui/menu/MenuState.kt | 1 + .../org/demoth/cake/ui/MenuStagesGlTest.kt | 19 ++- .../demoth/cake/ui/menu/MenuControllerTest.kt | 53 +++++++- .../jake2/server/save/SaveSlotDeletion.kt | 20 +++ .../jake2/server/save/SaveSlotDeletionTest.kt | 51 +++++++ 16 files changed, 359 insertions(+), 49 deletions(-) create mode 100644 server/src/main/kotlin/jake2/server/save/SaveSlotDeletion.kt create mode 100644 server/src/test/kotlin/jake2/server/save/SaveSlotDeletionTest.kt diff --git a/cake/README.md b/cake/README.md index bf7c92d6..a84914c4 100644 --- a/cake/README.md +++ b/cake/README.md @@ -138,8 +138,10 @@ New Game opens difficulty selection. The submenu stays accessible during play, with each action enabled when eligible; Back from these screens returns there. Escape goes back through the menus and returns to gameplay from the main menu. Saved games refresh automatically whenever Load Game or Save Game is opened. -New Save -creates a checkpoint; Overwrite asks before replacing a manual save. Autosave is +Each screen shows a scrolling checkpoint list alongside preview space and save +details, with actions along the bottom. Preview thumbnails are not captured yet. +New Save creates a checkpoint; Save asks before replacing the selected manual save. +Delete asks before removing the selected checkpoint. Autosave is listed for loading and reserved against manual overwrite. Save requires a living, spawned local player. Loading is available from startup and during singleplayer; the singleplayer respawn action opens Load Game automatically. diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index fedef86e..e8aad5a3 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -15,6 +15,7 @@ import jake2.server.ServerSaveCatalog import jake2.server.SavedGameEntry import jake2.server.SavedGameMode import jake2.server.save.ServerSaveJsonStore +import jake2.server.save.deleteSaveSlot import org.demoth.cake.hosting.* import org.demoth.cake.profile.CakeGameProfile import java.io.IOException @@ -255,6 +256,21 @@ class EmbeddedCakeHost( return checkNotNull(server).loadGame(source).toHostSaveResult(source) } + override fun deleteSave(slot: String): HostSaveResult { + checkThread() + saveAccess().loadRejectionReason?.let { return HostSaveResult.Rejected(it) } + saveStorageRejection()?.let { return HostSaveResult.Rejected(it) } + val target = normalizedSaveSlot(slot) ?: return HostSaveResult.Rejected("Invalid save slot.") + return try { + deleteSaveSlot(profileRoot(checkNotNull(activeProfile)), target) + HostSaveResult.Completed(target) + } catch (failure: IllegalArgumentException) { + HostSaveResult.Rejected(failure.message ?: "Invalid save directory.") + } catch (failure: Exception) { + HostSaveResult.Failed(failure.message ?: "Could not delete saved game.", failure) + } + } + private fun saveContextRejection(): String? = when { disposed || server == null -> "Host is not initialized." activeProfile == null -> "Select a game profile first." @@ -270,7 +286,7 @@ class EmbeddedCakeHost( profilesRoot.resolve(profile.id).toAbsolutePath().normalize() private fun normalizedSaveSlot(slot: String): String? = try { - ServerSaveJsonStore.normalizeSlot(slot) + ServerSaveJsonStore.normalizeSlot(slot).takeIf { it == slot } } catch (_: IllegalArgumentException) { null } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt index 14d97eea..75dc31d9 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt @@ -22,6 +22,7 @@ import org.demoth.cake.hosting.HostState import org.demoth.cake.profile.CakeGameProfile import org.demoth.cake.profile.CakeGameProfileStore import org.demoth.cake.stages.MenuStages +import org.demoth.cake.ui.writeScreenshot import org.demoth.cake.ui.menu.MenuEventBus import org.demoth.cake.ui.menu.MenuScreen import org.junit.jupiter.api.Assertions.* @@ -121,7 +122,7 @@ class CakeSaveMenusGlTest { } 4 -> { assertEquals(slot, state().pendingOverwriteSlot) - click("cancel-overwrite") + click("cancel-save-confirmation") next() } 5 -> { @@ -204,6 +205,32 @@ class CakeSaveMenusGlTest { } 17 -> if (active()) { assertEquals(41, player().health) + Cmd.ExecuteString("menu_savegame") + next() + } + 18 -> { click("save-new"); next() } + 19 -> { + assertNotEquals(slot, state().selectedSlot) + click("delete-save") + next() + } + 20 -> { + assertNotNull(state().pendingDeleteSlot) + click("cancel-save-confirmation") + next() + } + 21 -> { + assertNull(state().pendingDeleteSlot) + assertEquals(2, state().entries.count { !it.autosave }) + click("delete-save") + next() + } + 22 -> { click("confirm-delete"); next() } + 23 -> { + assertEquals("Saved game deleted.", state().statusMessage) + assertEquals(listOf(slot), state().entries.filter { !it.autosave }.map { it.slot }) + assertTrue(Files.exists(checkpoint())) + writeScreenshot(Gdx.files.local("build/reports/save-menu-layout.png")) Gdx.app.exit() } } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index a63de374..1517e2f5 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -188,6 +188,43 @@ class EmbeddedCakeSavesTest { assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) } + @Test + fun `deleting a checkpoint is profile scoped and leaves the loaded Game running`() { + start() + spawn() + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint")) + assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) + val live = host.server!!.games.getValue("default") + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave(" checkpoint ")) + assertTrue(Files.exists(root.resolve("first/save/checkpoint"))) + assertEquals(HostSaveResult.Completed("checkpoint"), host.deleteSave("checkpoint")) + assertSame(live, host.server!!.games["default"]) + assertFalse(entries().any { it.slot == "checkpoint" }) + assertTrue(Files.exists(root.resolve("first/save/current/game.ssv.json"))) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("current")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("../first")) + host.stop("profile switch") + host.activateProfile(profile.copy(id = "second")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) + assertTrue(Files.exists(root.resolve("first/save/save0/game.ssv.json"))) + Files.createDirectories(root.resolve("second/save/corrupt")) + Files.writeString(root.resolve("second/save/corrupt/junk"), "not a valid save") + assertEquals(HostSaveResult.Completed("corrupt"), host.deleteSave("corrupt")) + assertFalse(Files.exists(root.resolve("second/save/corrupt"))) + } + + @Test + fun `deletion rejects remote context and mismatched storage without removing the checkpoint`() { + start() + remote = true + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) + remote = false + EngineWriteRoot.setRoot(root.resolve("other")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) + assertTrue(Files.exists(root.resolve("first/save/save0/game.ssv.json"))) + EngineWriteRoot.setRoot(root.resolve("first")) + } + @Test fun `catalog IO failure is distinct from an empty save list`() { host.activateProfile(profile) diff --git a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt index 27692c23..a3e80260 100644 --- a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt @@ -16,6 +16,7 @@ class HostAvailabilityTest { assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame()) assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) } @Test 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 e6596768..5c530e58 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1593,6 +1593,7 @@ class Cake( override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = finishSaveOperation(host.saveGame(slot, overwrite)) override fun loadGame(slot: String): HostSaveResult = finishSaveOperation(host.loadGame(slot)) + override fun deleteSave(slot: String): HostSaveResult = host.deleteSave(slot) private fun finishSaveOperation(result: HostSaveResult): HostSaveResult { if (result is HostSaveResult.Failed && host.state == HostState.STOPPED) { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 595c42ee..1b328bef 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -31,6 +31,8 @@ interface CakeHost { /** Null creates a generated slot; replacing an existing manual slot requires overwrite. */ fun saveGame(slot: String? = null, overwrite: Boolean = false): HostSaveResult fun loadGame(slot: String): HostSaveResult + /** Delete one selected checkpoint after the caller has confirmed the action. */ + fun deleteSave(slot: String): HostSaveResult } enum class HostAvailability { REMOTE_ONLY, NOT_READY, AVAILABLE } @@ -72,4 +74,5 @@ class UnavailableCakeHost : CakeHost { override fun listSaves() = HostSaveCatalogResult.Rejected(saveUnavailable) override fun saveGame(slot: String?, overwrite: Boolean) = HostSaveResult.Rejected(saveUnavailable) override fun loadGame(slot: String) = HostSaveResult.Rejected(saveUnavailable) + override fun deleteSave(slot: String) = HostSaveResult.Rejected(saveUnavailable) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md index 8724bc36..bf0a1483 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md @@ -10,6 +10,8 @@ The developer console has [its own documentation](console/README.md). | Screen | Purpose | |---|---| | `MainMenuStage` | Main navigation and profile/options entry points | +| `SingleplayerMenuStage` | New Game, Load Game, Save Game and Back | +| `SavedGamesStage` | Scrolling checkpoint list, placeholder preview/details and confirmed save/delete actions | | `ProfileEditStage` | Profile selection, creation, autodetection, and editing | | `MultiplayerMenuStage` | Join Game, optional Host Game, and Player Setup | | `HostingSetupStage` | Singleplayer difficulty and multiplayer host settings | diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index bb833ee1..206cf72a 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -2,8 +2,11 @@ package org.demoth.cake.stages import com.badlogic.gdx.Input import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Cell import com.badlogic.gdx.scenes.scene2d.ui.Label -import com.badlogic.gdx.scenes.scene2d.ui.SelectBox +import com.badlogic.gdx.scenes.scene2d.ui.List as SceneList +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener import com.badlogic.gdx.utils.viewport.Viewport @@ -14,11 +17,11 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -/** Shared Load/Save presentation; catalog IO and overwrite decisions belong to the controller. */ +/** List and preview share a selection; IO and confirmation decisions belong to the controller. */ class SavedGamesStage( viewport: Viewport, menuEventBus: MenuEventBus, - style: GameUiStyle, + private val style: GameUiStyle, private val saving: Boolean, ) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenSingleplayerMenu) { private data class Choice(val entry: HostSaveEntry) { @@ -26,35 +29,54 @@ class SavedGamesStage( val date = entry.savedAtMillis?.let { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(it)) }.orEmpty() - val title = entry.mapName ?: entry.slot - return "${if (entry.autosave) "Autosave: " else ""}$title $date" + return "${if (entry.autosave) "Autosave: " else ""}${entry.mapName ?: entry.slot} $date" } } private var syncing = false private var renderedEntries: List? = null + private var renderedSlot: String? = null private val profile = Label("", style.menuWidgets.label) - private val slots = SelectBox(style.menuWidgets.selectBox).apply { name = "save-slots"; maxListCount = 10 } + private val slots = SceneList(style.menuWidgets.list).apply { name = "save-slots" } + private val listScroll = ScrollPane(slots, style.menuWidgets.scrollPane).apply { + name = "save-list-scroll" + setScrollingDisabled(true, false) + setFadeScrollBars(false) + setFlickScroll(false) + } private val detail = Label("", style.menuWidgets.label).apply { setWrap(true) } private val status = Label("", style.menuWidgets.label).apply { name = "save-status"; setWrap(true) } - private val primary = createMenuButton(if (saving) "New Save" else "Load Game", style.menuWidgets.button) { + private fun action(label: String, name: String, run: () -> Unit) = + createMenuButton(label, style.menuWidgets.button, run).apply { + this.name = name + labelCell.minWidth(0f) + } + private val newSave = action("New Save", "save-new") { + menuEventBus.postIntent(MenuIntent.SaveGameRequested(menuEventBus.latestState().savedGames.profileId)) + } + private val primary = action(if (saving) "Save" else "Load", if (saving) "save-overwrite" else "load-game") { val state = menuEventBus.latestState().savedGames - if (saving) menuEventBus.postIntent(MenuIntent.SaveGameRequested(state.profileId)) - else state.selectedSlot?.let { menuEventBus.postIntent(MenuIntent.LoadGameRequested(state.profileId, it)) } - }.apply { name = if (saving) "save-new" else "load-game" } - private val overwrite = createMenuButton("Overwrite", style.menuWidgets.button) { + state.selectedSlot?.let { + menuEventBus.postIntent(if (saving) MenuIntent.SaveGameRequested(state.profileId, it) + else MenuIntent.LoadGameRequested(state.profileId, it)) + } + } + private val delete = action("Delete", "delete-save") { + val state = menuEventBus.latestState().savedGames + state.selectedSlot?.let { menuEventBus.postIntent(MenuIntent.DeleteSaveRequested(state.profileId, it)) } + } + private val confirm = action("Replace Save", "confirm-overwrite") { val state = menuEventBus.latestState().savedGames - state.selectedSlot?.let { menuEventBus.postIntent(MenuIntent.SaveGameRequested(state.profileId, it)) } - }.apply { name = "save-overwrite" } + state.pendingDeleteSlot?.let { menuEventBus.postIntent(MenuIntent.ConfirmDeleteSave(state.profileId, it)) } + ?: state.pendingOverwriteSlot?.let { menuEventBus.postIntent(MenuIntent.ConfirmOverwrite(state.profileId, it)) } + } private val confirmation = Table().apply { - add(createMenuButton("Replace Save", style.menuWidgets.button) { - val state = menuEventBus.latestState().savedGames - state.pendingOverwriteSlot?.let { menuEventBus.postIntent(MenuIntent.ConfirmOverwrite(state.profileId, it)) } - }.apply { name = "confirm-overwrite" }).pad(4f) - add(createMenuButton("Cancel", style.menuWidgets.button) { - menuEventBus.postIntent(MenuIntent.CancelOverwrite) - }.apply { name = "cancel-overwrite" }).pad(4f) + add(confirm).pad(4f) + add(action("Cancel", "cancel-save-confirmation") { + menuEventBus.postIntent(MenuIntent.CancelSaveConfirmation) + }).pad(4f) } + private val confirmationCell: Cell init { slots.addListener(object : ChangeListener() { @@ -64,20 +86,40 @@ class SavedGamesStage( } } }) + val preview = Table().apply { + background = style.menuWidgets.controlPanel + name = "save-preview" + add(Label("No preview available", style.menuWidgets.label)).expand().center() + } + val right = Table().apply { + top() + add(preview).growX().height(216f).row() + add(detail).width(288f).growX().padTop(12f).top() + } + val body = Table().apply { + add(listScroll).width(440f).height(320f).padRight(16f) + add(right).width(300f).height(320f).top() + } + val actions = Table().apply { + defaults().uniformX().growX().pad(4f) + if (saving) add(newSave) + add(primary) + add(delete) + add(createBackButton(style = style.menuWidgets.button).apply { labelCell.minWidth(0f) }) + } val root = Table().apply { defaults().pad(6f).left() - add(Label(if (saving) "Save Game" else "Load Game", style.menuWidgets.label)).colspan(2).row() - add(profile).colspan(2).row() - add(slots).colspan(2).width(560f).row() - add(detail).colspan(2).width(560f).row() - add(primary).colspan(if (saving) 1 else 2) - if (saving) add(overwrite) + add(Label(if (saving) "Save Game" else "Load Game", style.menuWidgets.label)).row() + add(profile).row() + add(body).growX().row() + add(actions).growX().row() + confirmationCell = add(confirmation).growX().height(0f) row() - add(confirmation).colspan(2).row() - add(status).colspan(2).width(560f).row() - add(createBackButton(style = style.menuWidgets.button)).colspan(2).row() + add(status).width(756f).row() } addCenteredForm(root, style.menuWidgets.scrollPane) + scrollFocus = listScroll + keyboardFocus = slots } override fun act(delta: Float) { @@ -87,20 +129,29 @@ class SavedGamesStage( syncing = true if (renderedEntries !== state.entries) { renderedEntries = state.entries - slots.hideScrollPane() slots.setItems(*state.entries.map(::Choice).toTypedArray()) } slots.items.firstOrNull { it.entry.slot == state.selectedSlot }?.let { slots.selected = it } syncing = false + if (renderedSlot != state.selectedSlot) { + renderedSlot = state.selectedSlot + listScroll.layout() + listScroll.scrollTo(0f, slots.height - (slots.selectedIndex + 1) * slots.itemHeight, 0f, slots.itemHeight) + } val selected = state.entries.firstOrNull { it.slot == state.selectedSlot } - val pending = state.pendingOverwriteSlot != null + val pending = state.pendingOverwriteSlot != null || state.pendingDeleteSlot != null val reason = if (saving) snapshot.saveAccess.saveRejectionReason else snapshot.saveAccess.loadRejectionReason profile.setText("Profile: ${state.profileId.ifBlank { "" }}") - slots.isDisabled = state.entries.isEmpty() || pending - primary.isDisabled = pending || reason != null || (!saving && (selected == null || selected.loadRejectionReason != null)) - overwrite.isDisabled = pending || reason != null || selected == null || selected.autosave + slots.touchable = if (pending) Touchable.disabled else Touchable.enabled + slots.selection.isDisabled = pending + newSave.isDisabled = pending || reason != null + primary.isDisabled = pending || reason != null || selected == null || + (if (saving) selected.autosave else selected.loadRejectionReason != null) + delete.isDisabled = pending || selected == null || snapshot.saveAccess.loadRejectionReason != null + confirm.setText(if (state.pendingDeleteSlot != null) "Delete Save" else "Replace Save") + confirm.name = if (state.pendingDeleteSlot != null) "confirm-delete" else "confirm-overwrite" confirmation.isVisible = pending - confirmation.cells.forEach { it.height(if (pending) 68f else 0f) } + confirmationCell.height(if (pending) 68f else 0f) detail.setText(when { state.entries.isEmpty() -> "No saved games for this profile." saving && selected?.autosave == true -> "Autosave is reserved. Choose New Save or another slot." @@ -110,8 +161,9 @@ class SavedGamesStage( } override fun keyUp(keycode: Int): Boolean { - if (keycode == Input.Keys.ESCAPE && menuEventBus.latestState().savedGames.pendingOverwriteSlot != null) { - menuEventBus.postIntent(MenuIntent.CancelOverwrite) + val state = menuEventBus.latestState().savedGames + if (keycode == Input.Keys.ESCAPE && (state.pendingOverwriteSlot != null || state.pendingDeleteSlot != null)) { + menuEventBus.postIntent(MenuIntent.CancelSaveConfirmation) return true } return super.keyUp(keycode) 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 900cce29..662d709c 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 @@ -9,6 +9,7 @@ interface MenuBackend { fun listSaves(): HostSaveCatalogResult = HostSaveCatalogResult.Rejected("Save/load is unavailable.") fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = HostSaveResult.Rejected("Saving is unavailable.") fun loadGame(slot: String): HostSaveResult = HostSaveResult.Rejected("Loading is unavailable.") + fun deleteSave(slot: String): HostSaveResult = HostSaveResult.Rejected("Deleting saves is unavailable.") fun hostMaps(): List = emptyList() fun hostingUnavailableReason(): String? = "Hosting requires the Cake fullgame application." fun startHosting(singleplayer: Boolean, form: HostGameFormState): String = @@ -95,7 +96,7 @@ class MenuController( is MenuIntent.SelectSaveSlot -> if (isCurrentSaveProfile(intent.profileId) && state.savedGames.entries.any { it.slot == intent.slot }) { state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, - pendingOverwriteSlot = null, statusMessage = "")) + pendingOverwriteSlot = null, pendingDeleteSlot = null, statusMessage = "")) refreshState() } is MenuIntent.LoadGameRequested -> { @@ -111,7 +112,7 @@ class MenuController( handleSaveResult(backend.saveGame(null, overwrite = false), loading = false) } else if (state.savedGames.entries.any { it.slot == intent.slot && !it.autosave }) { state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, - pendingOverwriteSlot = intent.slot, statusMessage = "Replace this saved game?")) + pendingOverwriteSlot = intent.slot, pendingDeleteSlot = null, statusMessage = "Replace this saved game?")) refreshState() } } @@ -121,7 +122,34 @@ class MenuController( val reason = backend.saveAccess().saveRejectionReason if (reason != null) saveStatus(reason) else handleSaveResult(backend.saveGame(intent.slot, overwrite = true), loading = false) } - MenuIntent.CancelOverwrite -> saveStatus("") + is MenuIntent.DeleteSaveRequested -> { + if (!isSaveScreen() || !isCurrentSaveProfile(intent.profileId) || + state.savedGames.entries.none { it.slot == intent.slot }) return + val reason = backend.saveAccess().loadRejectionReason + if (reason != null) saveStatus(reason) else { + state = state.copy(savedGames = state.savedGames.copy(selectedSlot = intent.slot, + pendingDeleteSlot = intent.slot, pendingOverwriteSlot = null, statusMessage = "Delete this saved game?")) + refreshState() + } + } + is MenuIntent.ConfirmDeleteSave -> { + if (!isSaveScreen() || !isCurrentSaveProfile(intent.profileId) || + state.savedGames.pendingDeleteSlot != intent.slot) return + val reason = backend.saveAccess().loadRejectionReason + if (reason != null) saveStatus(reason) else { + val index = state.savedGames.entries.indexOfFirst { it.slot == intent.slot } + val remaining = state.savedGames.entries.filter { it.slot != intent.slot } + val nextSlot = remaining.getOrNull(index.coerceAtMost(remaining.lastIndex))?.slot + val result = backend.deleteSave(intent.slot) + refreshSavedGames(if (result is HostSaveResult.Completed) nextSlot else state.savedGames.selectedSlot) + saveStatus(when (result) { + is HostSaveResult.Completed -> "Saved game deleted." + is HostSaveResult.Rejected -> result.reason + is HostSaveResult.Failed -> "Delete failed: ${result.reason}" + }) + } + } + MenuIntent.CancelSaveConfirmation -> saveStatus("") is MenuIntent.OpenUiSample -> bus.postSignal(MenuSignal.OpenUiSample(intent.game)) MenuIntent.CloseUiSample -> bus.postSignal(MenuSignal.CloseUiSample) is MenuIntent.CaptureUiSample -> @@ -425,7 +453,8 @@ class MenuController( } private fun saveStatus(message: String) { - state = state.copy(savedGames = state.savedGames.copy(statusMessage = message, pendingOverwriteSlot = null)) + state = state.copy(savedGames = state.savedGames.copy(statusMessage = message, + pendingOverwriteSlot = null, pendingDeleteSlot = null)) refreshState() } 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 280069b9..acbbf04b 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 @@ -17,7 +17,9 @@ sealed interface MenuIntent { data class LoadGameRequested(val profileId: String, val slot: String) : MenuIntent data class SaveGameRequested(val profileId: String, val slot: String? = null) : MenuIntent data class ConfirmOverwrite(val profileId: String, val slot: String) : MenuIntent - data object CancelOverwrite : MenuIntent + data class DeleteSaveRequested(val profileId: String, val slot: String) : MenuIntent + data class ConfirmDeleteSave(val profileId: String, val slot: String) : MenuIntent + data object CancelSaveConfirmation : MenuIntent data object OpenMultiplayerMenu : MenuIntent data class OpenHostingSetup(val singleplayer: Boolean) : MenuIntent data class StartHosting(val singleplayer: Boolean, val form: HostGameFormState) : MenuIntent diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt index acbf3fe7..cfbda472 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuState.kt @@ -98,6 +98,7 @@ data class SavedGamesState( val selectedSlot: String? = null, val pendingOverwriteSlot: String? = null, val statusMessage: String = "", + val pendingDeleteSlot: String? = null, ) /** Raw text stays in the form so rejected input can be corrected without losing it. */ 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 267ef08f..b729759d 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 @@ -155,7 +155,7 @@ class MenuStagesGlTest { val entries = listOf( HostSaveEntry("save0", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null), HostSaveEntry("manual", "Checkpoint", "base2", 2000L, HostMode.SINGLEPLAYER, null), - ) + ) + (1..24).map { HostSaveEntry("manual-$it", "Checkpoint $it", "base2", 2000L, HostMode.SINGLEPLAYER, null) } val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, saveAccess = HostSaveAccess(null, null), savedGames = SavedGamesState("profile", entries, "save0"))) @@ -163,6 +163,13 @@ class MenuStagesGlTest { try { stage.act(0f) stage.draw() + val scroll = stage.root.findActor("save-list-scroll") + val preview = stage.root.findActor("save-preview") + assertTrue(scroll.widget is com.badlogic.gdx.scenes.scene2d.ui.List<*>) + assertTrue(scroll.maxY > 0, "Checkpoint list must scroll within its own pane") + val listPosition = scroll.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) + val previewPosition = preview.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) + assertTrue(previewPosition.x >= listPosition.x + scroll.width) val primary = stage.root.findActor("save-new") val overwrite = stage.root.findActor("save-overwrite") assertFalse(primary.isDisabled) @@ -183,7 +190,15 @@ class MenuStagesGlTest { assertTrue(intents.contains(MenuIntent.ConfirmOverwrite("profile", "manual"))) assertTrue(stage.keyUp(Input.Keys.ESCAPE)) bus.drainIntents { intents += it } - assertEquals(MenuIntent.CancelOverwrite, intents.last()) + assertEquals(MenuIntent.CancelSaveConfirmation, intents.last()) + bus.postState(bus.latestState().copy(savedGames = bus.latestState().savedGames.copy( + pendingOverwriteSlot = null, pendingDeleteSlot = "manual", statusMessage = "Delete this saved game?"))) + stage.act(0f) + val deleteConfirm = stage.root.findActor("confirm-delete") + assertEquals("Delete Save", deleteConfirm.text.toString()) + deleteConfirm.listeners.filterIsInstance().forEach { it.clicked(InputEvent(), 0f, 0f) } + bus.drainIntents { intents += it } + assertEquals(MenuIntent.ConfirmDeleteSave("profile", "manual"), intents.last()) bus.postState(bus.latestState().copy(savedGames = SavedGamesState("other"))) stage.act(0f) assertTrue(overwrite.isDisabled) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index 44ab0f55..de17cddd 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -40,6 +40,49 @@ class MenuControllerTest { private fun savedEntry(slot: String, reason: String? = null) = HostSaveEntry(slot, "Checkpoint", "base1", 1000L, HostMode.SINGLEPLAYER, reason) + @Test + fun deletionRequiresCurrentProfileConfirmationAndSelectsTheNextCheckpoint() { + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("one"), savedEntry("two"), savedEntry("three")) } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + fun post(intent: MenuIntent) { bus.postIntent(intent); controller.pumpIntents() } + post(MenuIntent.OpenLoadGame) + post(MenuIntent.DeleteSaveRequested("default", "two")) + assertEquals("two", bus.latestState().savedGames.pendingDeleteSlot) + assertTrue(backend.deletedSaves.isEmpty()) + post(MenuIntent.CancelSaveConfirmation) + post(MenuIntent.ConfirmDeleteSave("default", "two")) + assertTrue(backend.deletedSaves.isEmpty()) + post(MenuIntent.DeleteSaveRequested("default", "two")) + post(MenuIntent.ConfirmDeleteSave("default", "two")) + assertEquals(listOf("two"), backend.deletedSaves) + assertEquals("three", bus.latestState().savedGames.selectedSlot) + assertEquals("Saved game deleted.", bus.latestState().savedGames.statusMessage) + post(MenuIntent.DeleteSaveRequested("default", "one")) + backend.applyProfile("other") + post(MenuIntent.ConfirmDeleteSave("default", "one")) + assertEquals(listOf("two"), backend.deletedSaves) + } + + @Test + fun deleteFailureRemainsVisibleWithoutLosingSelectionOrClosingMenu() { + val backend = FakeMenuBackend().apply { + saves = listOf(savedEntry("corrupt", "Invalid save")) + deleteOutcome = HostSaveResult.Failed("Permission denied", java.io.IOException()) + } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + bus.postIntent(MenuIntent.OpenSaveGame) + bus.postIntent(MenuIntent.DeleteSaveRequested("default", "corrupt")) + bus.postIntent(MenuIntent.ConfirmDeleteSave("default", "corrupt")) + controller.pumpIntents() + assertEquals(MenuScreen.SAVE_GAME, bus.latestState().activeScreen) + assertEquals("corrupt", bus.latestState().savedGames.selectedSlot) + assertEquals("Delete failed: Permission denied", bus.latestState().savedGames.statusMessage) + } + @Test fun saveCatalogRefreshesOnEntryAndProfileChangeWithoutPerFrameIO() { val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("manual")) } @@ -75,7 +118,7 @@ class MenuControllerTest { post(MenuIntent.SaveGameRequested("default", "manual")) assertEquals("manual", bus.latestState().savedGames.pendingOverwriteSlot) assertTrue(backend.savesWritten.isEmpty()) - post(MenuIntent.CancelOverwrite) + post(MenuIntent.CancelSaveConfirmation) post(MenuIntent.ConfirmOverwrite("default", "manual")) assertTrue(backend.savesWritten.isEmpty()) post(MenuIntent.SaveGameRequested("default", "manual")) @@ -601,6 +644,14 @@ class MenuControllerTest { var loadOutcome: HostSaveResult = HostSaveResult.Completed("manual") val loads = mutableListOf() val savesWritten = mutableListOf>() + val deletedSaves = mutableListOf() + var deleteOutcome: HostSaveResult? = null + override fun deleteSave(slot: String): HostSaveResult { + deletedSaves += slot + val result = deleteOutcome ?: HostSaveResult.Completed(slot) + if (result is HostSaveResult.Completed) saves = saves.filter { it.slot != slot } + return result + } override fun saveAccess() = access override fun listSaves(): HostSaveCatalogResult { saveListReads++ diff --git a/server/src/main/kotlin/jake2/server/save/SaveSlotDeletion.kt b/server/src/main/kotlin/jake2/server/save/SaveSlotDeletion.kt new file mode 100644 index 00000000..991c2966 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/save/SaveSlotDeletion.kt @@ -0,0 +1,20 @@ +package jake2.server.save + +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path + +/** Remove one complete checkpoint, including future sidecar assets, without following directory links. */ +fun deleteSaveSlot(root: Path, slot: String) { + val name = ServerSaveJsonStore.normalizeSlot(slot) + require(name == slot) { "Invalid save slot." } + require(name != "current") { "The working save is internal." } + val saves = root.resolve("save") + val directory = saves.resolve(name) + require(Files.isDirectory(saves, NOFOLLOW_LINKS) && Files.isDirectory(directory, NOFOLLOW_LINKS)) { + "Save directory is missing or is a link." + } + Files.walk(directory).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) } + } +} diff --git a/server/src/test/kotlin/jake2/server/save/SaveSlotDeletionTest.kt b/server/src/test/kotlin/jake2/server/save/SaveSlotDeletionTest.kt new file mode 100644 index 00000000..94d9c18b --- /dev/null +++ b/server/src/test/kotlin/jake2/server/save/SaveSlotDeletionTest.kt @@ -0,0 +1,51 @@ +package jake2.server.save + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +class SaveSlotDeletionTest { + @TempDir lateinit var root: Path + + @Test + fun `deletion removes the entire selected checkpoint and preserves siblings and working state`() { + for (slot in listOf("manual", "other", "current")) { + Files.createDirectories(root.resolve("save/$slot")) + Files.writeString(root.resolve("save/$slot/game.ssv.json"), slot) + } + Files.createDirectories(root.resolve("save/manual/sidecars")) + Files.writeString(root.resolve("save/manual/sidecars/preview.png"), "preview") + assertThrows(IllegalArgumentException::class.java) { deleteSaveSlot(root, " manual ") } + assertTrue(Files.exists(root.resolve("save/manual/game.ssv.json"))) + deleteSaveSlot(root, "manual") + assertFalse(Files.exists(root.resolve("save/manual"))) + assertEquals("other", Files.readString(root.resolve("save/other/game.ssv.json"))) + assertEquals("current", Files.readString(root.resolve("save/current/game.ssv.json"))) + } + + @Test + fun `invalid internal missing and linked slots are rejected without changing outside files`() { + Files.createDirectories(root.resolve("save/current")) + val outside = Files.createDirectory(root.resolve("outside")) + Files.writeString(outside.resolve("sentinel"), "keep") + Files.createSymbolicLink(root.resolve("save/linked"), outside) + for (slot in listOf("current", "../outside", "linked", "missing", "")) { + assertThrows(IllegalArgumentException::class.java) { deleteSaveSlot(root, slot) } + } + assertEquals("keep", Files.readString(outside.resolve("sentinel"))) + assertTrue(Files.isDirectory(root.resolve("save/current"))) + } + + @Test + fun `nested links are deleted without following them`() { + val outside = Files.createDirectory(root.resolve("outside")) + Files.writeString(outside.resolve("sentinel"), "keep") + Files.createDirectories(root.resolve("save/manual")) + Files.createSymbolicLink(root.resolve("save/manual/nested"), outside) + deleteSaveSlot(root, "manual") + assertFalse(Files.exists(root.resolve("save/manual"))) + assertEquals("keep", Files.readString(outside.resolve("sentinel"))) + } +} From ac50ab407a9d09824f6523bb19f98460f24b0e60 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 16:15:29 +0100 Subject: [PATCH 13/57] Label save checkpoints with date, map title and map name --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 3 ++- .../jake2/server/EmbeddedCakeSavesTest.kt | 2 ++ .../org/demoth/cake/hosting/HostSaves.kt | 1 + .../org/demoth/cake/stages/SavedGamesStage.kt | 11 +++++++---- .../org/demoth/cake/ui/MenuStagesGlTest.kt | 4 +++- .../java/jake2/server/GameImportsImpl.java | 2 +- .../server/save/ServerSaveJsonStore.java | 9 +++++++-- .../kotlin/jake2/server/ServerSaveCatalog.kt | 18 +++++++++++++++++- .../server/save/ServerSaveJsonStoreTest.java | 3 ++- .../jake2/server/ServerSaveCatalogTest.kt | 19 ++++++++++++++++++- 10 files changed, 60 insertions(+), 12 deletions(-) diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index e8aad5a3..b00a483a 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -299,7 +299,8 @@ class EmbeddedCakeHost( null -> null } return HostSaveEntry(entry.slot, entry.comment, entry.mapName, entry.savedAtMillis, mode, - entry.problem ?: if (mode != HostMode.SINGLEPLAYER) "This save is not a singleplayer game." else null) + entry.problem ?: if (mode != HostMode.SINGLEPLAYER) "This save is not a singleplayer game." else null, + entry.mapTitle) } private fun ServerHost.OperationResult.toHostSaveResult(slot: String): HostSaveResult = when (this) { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index 1517e2f5..63d5bb7d 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -114,6 +114,8 @@ class EmbeddedCakeSavesTest { } val created = assertInstanceOf(HostSaveResult.Completed::class.java, host.saveGame()) assertTrue(created.slot.startsWith("manual-")) + assertEquals(host.server!!.games.getValue("default").configstrings[Defines.CS_NAME], + entries().single { it.slot == created.slot }.mapTitle) val file = root.resolve("first/save/${created.slot}/game.ssv.json") val before = Files.readAllBytes(file) assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame(created.slot)) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt index b51c0a86..4e30e44c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt @@ -11,6 +11,7 @@ data class HostSaveEntry( val savedAtMillis: Long?, val mode: HostMode?, val loadRejectionReason: String?, + val mapTitle: String? = null, ) { val autosave: Boolean get() = slot == "save0" } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index 206cf72a..31ec6766 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -28,8 +28,10 @@ class SavedGamesStage( override fun toString(): String { val date = entry.savedAtMillis?.let { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(it)) - }.orEmpty() - return "${if (entry.autosave) "Autosave: " else ""}${entry.mapName ?: entry.slot} $date" + } ?: "Unknown date" + val map = entry.mapName ?: entry.slot + val title = entry.mapTitle?.replace(Regex("\\s+"), " ")?.trim()?.takeIf { it.isNotEmpty() } ?: map + return "$date - $title - $map" } } @@ -40,7 +42,7 @@ class SavedGamesStage( private val slots = SceneList(style.menuWidgets.list).apply { name = "save-slots" } private val listScroll = ScrollPane(slots, style.menuWidgets.scrollPane).apply { name = "save-list-scroll" - setScrollingDisabled(true, false) + setScrollingDisabled(false, false) setFadeScrollBars(false) setFlickScroll(false) } @@ -155,7 +157,8 @@ class SavedGamesStage( detail.setText(when { state.entries.isEmpty() -> "No saved games for this profile." saving && selected?.autosave == true -> "Autosave is reserved. Choose New Save or another slot." - else -> listOfNotNull(selected?.comment, selected?.loadRejectionReason).joinToString("\n") + else -> listOfNotNull(if (selected?.autosave == true) "Autosave" else null, + selected?.comment, selected?.loadRejectionReason).joinToString("\n") }) status.setText(state.statusMessage.ifBlank { reason.orEmpty() }) } 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 b729759d..742ecc33 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 @@ -153,7 +153,7 @@ class MenuStagesGlTest { private fun verifySavedGames(viewport: UiViewport, style: GameUiStyle) { val entries = listOf( - HostSaveEntry("save0", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null), + HostSaveEntry("save0", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null, mapTitle = "Outer Base"), HostSaveEntry("manual", "Checkpoint", "base2", 2000L, HostMode.SINGLEPLAYER, null), ) + (1..24).map { HostSaveEntry("manual-$it", "Checkpoint $it", "base2", 2000L, HostMode.SINGLEPLAYER, null) } val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, @@ -166,6 +166,8 @@ class MenuStagesGlTest { val scroll = stage.root.findActor("save-list-scroll") val preview = stage.root.findActor("save-preview") assertTrue(scroll.widget is com.badlogic.gdx.scenes.scene2d.ui.List<*>) + val firstRow = (scroll.widget as com.badlogic.gdx.scenes.scene2d.ui.List<*>).items.first().toString() + assertTrue(firstRow.matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} - Outer Base - base1")), firstRow) assertTrue(scroll.maxY > 0, "Checkpoint list must scroll within its own pane") val listPosition = scroll.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) val previewPosition = preview.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 2c48159f..bdeb4de3 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -1220,7 +1220,7 @@ void SV_WriteServerFile(boolean autosave) { comment = new Date() + " " + configstrings[Defines.CS_NAME]; } - store.writeMapCommand("current", comment, mapcmd); + store.writeMapCommand("current", comment, mapcmd, configstrings[Defines.CS_NAME]); writeLatchedCvars(store); } catch (Exception e) { diff --git a/server/src/main/java/jake2/server/save/ServerSaveJsonStore.java b/server/src/main/java/jake2/server/save/ServerSaveJsonStore.java index 222c67ee..f2f95a39 100644 --- a/server/src/main/java/jake2/server/save/ServerSaveJsonStore.java +++ b/server/src/main/java/jake2/server/save/ServerSaveJsonStore.java @@ -50,7 +50,11 @@ public ServerMapCommandSnapshot readMapCommand(String slot) throws IOException { } public void writeMapCommand(String slot, String comment, String mapCommand) throws IOException { - write(mapCommandPath(slot), new ServerMapCommandSnapshot(SCHEMA_VERSION, comment, mapCommand)); + writeMapCommand(slot, comment, mapCommand, null); + } + + public void writeMapCommand(String slot, String comment, String mapCommand, String mapTitle) throws IOException { + write(mapCommandPath(slot), new ServerMapCommandSnapshot(SCHEMA_VERSION, comment, mapCommand, mapTitle)); } public ServerLatchedCvarsSnapshot readLatchedCvars(String slot) throws IOException { @@ -108,7 +112,8 @@ public static String normalizeSlot(String slot) { public record ServerMapCommandSnapshot( int schemaVersion, String comment, - String mapCommand + String mapCommand, + String mapTitle ) { } diff --git a/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt b/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt index a262b91e..32ca7c46 100644 --- a/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt +++ b/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt @@ -3,6 +3,7 @@ package jake2.server import jake2.qcommon.Defines import jake2.qcommon.exec.CvarTypes import jake2.server.save.ServerSaveJsonStore +import jake2.server.save.ServerLevelJsonStore import java.nio.file.Files import java.nio.file.LinkOption.NOFOLLOW_LINKS import java.nio.file.Path @@ -16,6 +17,7 @@ data class SavedGameEntry( val savedAtMillis: Long?, val mode: SavedGameMode?, val problem: String?, + val mapTitle: String? = null, ) /** Cheap, read-only browsing of an explicit root. Full restoration checks remain in SavePreflight. */ @@ -35,6 +37,7 @@ class ServerSaveCatalog(private val root: Path) { var mapName: String? = null var timestamp: Long? = null var mode: SavedGameMode? = null + var mapTitle: String? = null val problem = try { require(ServerSaveJsonStore.normalizeSlot(slot) == slot) { "Invalid save slot" } require(slot != "current") { "The working save is internal" } @@ -52,6 +55,9 @@ class ServerSaveCatalog(private val root: Path) { !target.mapName.contains('/') && !target.mapName.contains('\\')) { "Invalid saved map name" } comment = map.comment() mapName = target.mapName + mapTitle = map.mapTitle()?.takeIf { it.isNotBlank() } + ?: archivedTitle(slot, target.mapName) + ?: comment?.takeIf { it.startsWith("Autosave in ") }?.removePrefix("Autosave in ") require(Files.isRegularFile(directory.resolve("server_latched_cvars.ssv.json"), NOFOLLOW_LINKS)) { "Missing saved settings" } @@ -74,6 +80,16 @@ class ServerSaveCatalog(private val root: Path) { } catch (failure: Exception) { failure.message ?: "Unreadable save" } - return SavedGameEntry(slot, comment, mapName, timestamp, mode, problem) + return SavedGameEntry(slot, comment, mapName, timestamp, mode, problem, mapTitle) + } + + // Existing manual checkpoints already contain the display title in the archived configstrings. + private fun archivedTitle(slot: String, map: String): String? = try { + if (Files.isRegularFile(root.resolve("save/$slot/$map.sv2.json"), NOFOLLOW_LINKS)) { + ServerLevelJsonStore.forWriteDir(root.toString()).readLevelState(slot, map) + .configstrings.getOrNull(Defines.CS_NAME)?.takeIf { it.isNotBlank() } + } else null + } catch (_: Exception) { + null // Missing title must not turn metadata browsing into a full restoration check. } } diff --git a/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java b/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java index e2075f72..9c89b360 100644 --- a/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java +++ b/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java @@ -20,13 +20,14 @@ class ServerSaveJsonStoreTest { void roundTripsServerMapCommandSnapshot() throws Exception { ServerSaveJsonStore store = new ServerSaveJsonStore(new DefaultWritableFileSystem(temp)); - store.writeMapCommand("current", "Autosave in base1", "gamemap \"*base1\""); + store.writeMapCommand("current", "Autosave in base1", "gamemap \"*base1\"", "Outer Base"); assertTrue(store.hasMapCommand("current")); ServerSaveJsonStore.ServerMapCommandSnapshot snapshot = store.readMapCommand("current"); assertEquals(ServerSaveJsonStore.SCHEMA_VERSION, snapshot.schemaVersion()); assertEquals("Autosave in base1", snapshot.comment()); assertEquals("gamemap \"*base1\"", snapshot.mapCommand()); + assertEquals("Outer Base", snapshot.mapTitle()); } @Test diff --git a/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt index 89ef4fb0..e8c2ae2c 100644 --- a/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt @@ -14,7 +14,7 @@ class ServerSaveCatalogTest { private fun metadata(slot: String, time: Long, coop: Boolean = false, deathmatch: Boolean = false) { val store = ServerSaveJsonStore.forWriteDir(root.toString()) - store.writeMapCommand(slot, "Checkpoint $slot", "*testbox") + store.writeMapCommand(slot, "Checkpoint $slot", "*testbox", "Test Box") store.writeLatchedCvars(slot, listOf( LatchedCvarSnapshot("maxclients", if (coop || deathmatch) "4" else "1"), LatchedCvarSnapshot("coop", if (coop) "1" else "0"), @@ -46,12 +46,29 @@ class ServerSaveCatalogTest { assertEquals(listOf("save0", "coop", "deathmatch", "console checkpoint"), entries.filter { it.problem == null }.map { it.slot }) assertEquals("testbox", entries.first { it.slot == "save0" }.mapName) + assertEquals("Test Box", entries.first { it.slot == "save0" }.mapTitle) assertEquals(SavedGameMode.COOP, entries.first { it.slot == "coop" }.mode) assertEquals(SavedGameMode.DEATHMATCH, entries.first { it.slot == "deathmatch" }.mode) assertNotNull(entries.first { it.slot == "broken" }.problem) assertNotNull(entries.first { it.slot == "incomplete" }.problem) } + @Test + fun `existing saves get map titles from level archives or autosave comments`() { + metadata("manual", 1) + metadata("save0", 2) + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + store.writeMapCommand("manual", "old checkpoint comment", "testbox") + store.writeMapCommand("save0", "Autosave in Entry Title", "testbox") + val strings = arrayOfNulls(jake2.qcommon.Defines.MAX_CONFIGSTRINGS) + strings[jake2.qcommon.Defines.CS_NAME] = "Archived Title" + jake2.server.save.ServerLevelJsonStore.forWriteDir(root.toString()) + .writeLevelState("manual", "testbox", strings, BooleanArray(0)) + val entries = ServerSaveCatalog(root).list().associateBy { it.slot } + assertEquals("Archived Title", entries.getValue("manual").mapTitle) + assertEquals("Entry Title", entries.getValue("save0").mapTitle) + } + @Test fun `bad versions settings and missing gameplay file do not appear usable`() { metadata("version", 1) From 1d3fb4259ad6540a7c460e6c73b2cc21c3f54917 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 16:36:07 +0100 Subject: [PATCH 14/57] Name the automatic checkpoint slot autosave --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 2 +- .../jake2/server/CakeHostApplicationTest.kt | 2 +- .../jake2/server/EmbeddedCakeHostTest.kt | 6 ++-- .../jake2/server/EmbeddedCakeSavesTest.kt | 28 +++++++++---------- .../jake2/server/GameContinuationTest.kt | 6 ++-- .../server/HostSessionPreparationTest.kt | 6 ++-- .../jake2/server/TransitionArchiveTest.kt | 2 +- .../cake/fullgame/HostAvailabilityTest.kt | 4 +-- .../org/demoth/cake/hosting/HostSaves.kt | 2 +- .../org/demoth/cake/ui/MenuStagesGlTest.kt | 4 +-- .../demoth/cake/ui/menu/MenuControllerTest.kt | 4 +-- .../main/kotlin/jake2/server/ServerHost.kt | 4 +-- .../server/save/ServerSaveFilesTest.java | 4 +-- .../jake2/server/ServerSaveCatalogTest.kt | 14 +++++----- 14 files changed, 44 insertions(+), 44 deletions(-) diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index b00a483a..845cacba 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -234,7 +234,7 @@ class EmbeddedCakeHost( saveStorageRejection()?.let { return HostSaveResult.Rejected(it) } val destination = normalizedSaveSlot(slot ?: "manual-${UUID.randomUUID()}") ?: return HostSaveResult.Rejected("Invalid save slot.") - if (destination == "current" || destination == "save0") { + if (destination == "current" || destination == "autosave") { return HostSaveResult.Rejected("This slot is reserved for the game.") } val path = profileRoot(checkNotNull(activeProfile)).resolve("save").resolve(destination) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index 9e1ffb2d..937a1fe9 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -123,7 +123,7 @@ class CakeHostApplicationTest { assertEquals("CONNECTING", clientState(cake!!)) val current = temp.resolve(".cake/application-test/save/current/game.ssv.json") val before = Files.readAllBytes(current) - Cbuf.AddText("map testbox2; gamemap *testbox2; load save0") + Cbuf.AddText("map testbox2; gamemap *testbox2; load autosave") cake!!.render() assertEquals("CONNECTING", clientState(cake!!)) assertEquals(HostState.STOPPED, host.state) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 3607ed8c..e59dc25f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -100,7 +100,7 @@ class EmbeddedCakeHostTest { assertEquals(temp.resolve("session"), EngineWriteRoot.path()) } // Loading from idle must also work on the existing manager. - assertEquals(ServerHost.OperationResult.Completed, manager.loadGame("save0")) + assertEquals(ServerHost.OperationResult.Completed, manager.loadGame("autosave")) host.stop("done") host.dispose() host.dispose() @@ -236,7 +236,7 @@ class EmbeddedCakeHostTest { assertEquals(2, game().gameCvars.skill.typedValue) assertEquals("", cvars.find("gamedir").typedValue) assertEquals(temp.resolve("session"), EngineWriteRoot.path()) - assertTrue(Files.exists(temp.resolve("session/save/save0/game.ssv.json"))) + assertTrue(Files.exists(temp.resolve("session/save/autosave/game.ssv.json"))) assertTrue(NET.ip_sockets.all { it == null }) val frame = game().level.framenum host.update(100) @@ -266,7 +266,7 @@ class EmbeddedCakeHostTest { assertEquals("custom", cvars.find("game").typedValue) assertEquals("custom", cvars.find("gamedir").typedValue) assertEquals("modmap", cvars.find("mapname").typedValue) - assertTrue(Files.exists(temp.resolve("mod-session/save/save0/game.ssv.json"))) + assertTrue(Files.exists(temp.resolve("mod-session/save/autosave/game.ssv.json"))) host.stop("switch back") assertEquals(HostStartResult.Started, host.start(request())) assertEquals("", cvars.find("gamedir").typedValue) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index 63d5bb7d..f8e41900 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -74,22 +74,22 @@ class EmbeddedCakeSavesTest { remote = true assertNotNull(host.saveAccess().loadRejectionReason) assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("autosave")) assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame("slot", true)) assertFalse(Files.exists(root.resolve("first/save"))) host.dispose() assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("autosave")) } @Test fun `catalog and load follow selected profile while idle and use profile rather than shared root`() { start() - ServerSaveFiles(root.resolve("first")).copy("save0", "console slot") + ServerSaveFiles(root.resolve("first")).copy("autosave", "console slot") host.stop("browse") - assertEquals(setOf("save0", "console slot"), entries().map { it.slot }.toSet()) + assertEquals(setOf("autosave", "console slot"), entries().map { it.slot }.toSet()) EngineWriteRoot.setRoot(root.resolve("unrelated")) - assertEquals(setOf("save0", "console slot"), entries().map { it.slot }.toSet()) + assertEquals(setOf("autosave", "console slot"), entries().map { it.slot }.toSet()) assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("console slot")) EngineWriteRoot.setRoot(root.resolve("first")) host.activateProfile(profile.copy(id = "second")) @@ -109,7 +109,7 @@ class EmbeddedCakeSavesTest { assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame("dead")) spawn() assertNull(host.saveAccess().saveRejectionReason) - for (slot in listOf("current", "save0", "../outside")) { + for (slot in listOf("current", "autosave", "../outside")) { assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame(slot, true)) } val created = assertInstanceOf(HostSaveResult.Completed::class.java, host.saveGame()) @@ -150,13 +150,13 @@ class EmbeddedCakeSavesTest { val manager = host.server!! val live = manager.games.getValue("default") val files = ServerSaveFiles(root.resolve("first")) - files.copy("save0", "corrupt") + files.copy("autosave", "corrupt") Files.writeString(root.resolve("first/save/corrupt/server_mapcmd.ssv.json"), "bad json") assertNotNull(entries().single { it.slot == "corrupt" }.loadRejectionReason) - assertNull(entries().single { it.slot == "save0" }.loadRejectionReason) + assertNull(entries().single { it.slot == "autosave" }.loadRejectionReason) assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("corrupt")) // Browsing reads metadata only; authoritative preflight still rejects a corrupt game envelope. - files.copy("save0", "bad-game") + files.copy("autosave", "bad-game") Files.writeString(root.resolve("first/save/bad-game/game.ssv.json"), "bad json") assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("bad-game")) assertSame(live, manager.games["default"]) @@ -207,8 +207,8 @@ class EmbeddedCakeSavesTest { assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("../first")) host.stop("profile switch") host.activateProfile(profile.copy(id = "second")) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) - assertTrue(Files.exists(root.resolve("first/save/save0/game.ssv.json"))) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) + assertTrue(Files.exists(root.resolve("first/save/autosave/game.ssv.json"))) Files.createDirectories(root.resolve("second/save/corrupt")) Files.writeString(root.resolve("second/save/corrupt/junk"), "not a valid save") assertEquals(HostSaveResult.Completed("corrupt"), host.deleteSave("corrupt")) @@ -219,11 +219,11 @@ class EmbeddedCakeSavesTest { fun `deletion rejects remote context and mismatched storage without removing the checkpoint`() { start() remote = true - assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) remote = false EngineWriteRoot.setRoot(root.resolve("other")) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) - assertTrue(Files.exists(root.resolve("first/save/save0/game.ssv.json"))) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) + assertTrue(Files.exists(root.resolve("first/save/autosave/game.ssv.json"))) EngineWriteRoot.setRoot(root.resolve("first")) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index d68b4c19..390b34d0 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -72,7 +72,7 @@ class GameContinuationTest { @ValueSource(booleans = [false, true]) fun `continuation autosave eligibility follows its Game mode`(deathmatch: Boolean) { assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) - val autosave = root.resolve("save/save0") + val autosave = root.resolve("save/autosave") fun contents() = Files.walk(autosave).use { paths -> paths.filter(Files::isRegularFile).toList() .associate { autosave.relativize(it).toString() to Files.readAllBytes(it).toList() } } val before = contents() @@ -93,7 +93,7 @@ class GameContinuationTest { if (deathmatch) assertEquals(before, contents()) else { assertNotEquals(before, contents()) assertEquals("testbox2", jake2.server.save.ServerSaveJsonStore.forWriteDir(root.toString()) - .readMapCommand("save0").mapCommand()) + .readMapCommand("autosave").mapCommand()) } } @@ -381,7 +381,7 @@ class GameContinuationTest { assertTrue(NET.configureServer(true)) val broken = when (stage) { "wipe" -> root.resolve("save/current/game.ssv.json") - "autosave" -> root.resolve("save/save0/game.ssv.json") + "autosave" -> root.resolve("save/autosave/game.ssv.json") else -> root.resolve("save/current/testbox.sav.json") } if (stage == "restore") { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index e9611862..916844d6 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -61,7 +61,7 @@ class HostSessionPreparationTest { assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) host.stop("start deathmatch", false) } - val autosave = root.resolve("save/save0") + val autosave = root.resolve("save/autosave") fun contents(): Map> = if (!Files.exists(autosave)) emptyMap() else Files.walk(autosave).use { paths -> paths.filter(Files::isRegularFile).toList() .associate { autosave.relativize(it).toString() to Files.readAllBytes(it).toList() } } @@ -314,7 +314,7 @@ class HostSessionPreparationTest { fun `level entry autosave without archives remains loadable`() { host.map(listOf("map", "testbox")) host.stop("test", false) - host.loadGame(listOf("load", "save0")) + host.loadGame(listOf("load", "autosave")) assertEquals("testbox", host.games.getValue("default").mapName) } @@ -450,7 +450,7 @@ class HostSessionPreparationTest { @ValueSource(booleans = [false, true]) fun `map autosave failure leaves idle Host and permits restart`(newSession: Boolean) { save(false) - val blocked = root.resolve("save/save0/game.ssv.json") + val blocked = root.resolve("save/autosave/game.ssv.json") Files.delete(blocked) Files.createDirectory(blocked) Files.writeString(blocked.resolve("blocker"), "prevent replacement") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index 8ba39151..c8d809ab 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -111,7 +111,7 @@ class TransitionArchiveTest { assertSame(originalGameplay, first.gameExports) assertEquals(ServerStates.SS_GAME, first.state) assertEquals("default", host.clients[0].gameName) - assertFalse(Files.exists(root.resolve("save/save0")), "Failed archival must not continue to autosave") + assertFalse(Files.exists(root.resolve("save/autosave")), "Failed archival must not continue to autosave") if (extension == "sv2") { assertFalse(Files.exists(root.resolve("save/current/testbox.sav.json")), "Server-state failure must stop before gameplay serialization") diff --git a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt index a3e80260..0753e2c5 100644 --- a/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/org/demoth/cake/fullgame/HostAvailabilityTest.kt @@ -15,8 +15,8 @@ class HostAvailabilityTest { assertNotNull(host.saveAccess().saveRejectionReason) assertInstanceOf(HostSaveCatalogResult.Rejected::class.java, host.listSaves()) assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame()) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("save0")) - assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("save0")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("autosave")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) } @Test diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt index 4e30e44c..06720b99 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt @@ -13,7 +13,7 @@ data class HostSaveEntry( val loadRejectionReason: String?, val mapTitle: String? = null, ) { - val autosave: Boolean get() = slot == "save0" + val autosave: Boolean get() = slot == "autosave" } sealed interface HostSaveCatalogResult { 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 742ecc33..a7ef4611 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 @@ -153,12 +153,12 @@ class MenuStagesGlTest { private fun verifySavedGames(viewport: UiViewport, style: GameUiStyle) { val entries = listOf( - HostSaveEntry("save0", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null, mapTitle = "Outer Base"), + HostSaveEntry("autosave", "Autosave", "base1", 1000L, HostMode.SINGLEPLAYER, null, mapTitle = "Outer Base"), HostSaveEntry("manual", "Checkpoint", "base2", 2000L, HostMode.SINGLEPLAYER, null), ) + (1..24).map { HostSaveEntry("manual-$it", "Checkpoint $it", "base2", 2000L, HostMode.SINGLEPLAYER, null) } val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, saveAccess = HostSaveAccess(null, null), - savedGames = SavedGamesState("profile", entries, "save0"))) + savedGames = SavedGamesState("profile", entries, "autosave"))) val stage = SavedGamesStage(viewport, bus, style, saving = true) try { stage.act(0f) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index de17cddd..103257c9 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -107,13 +107,13 @@ class MenuControllerTest { @Test fun saveRequiresConfirmationAndRejectsStaleProfileAndAutosaveRequests() { - val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("save0"), savedEntry("manual")) } + val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("autosave"), savedEntry("manual")) } val bus = MenuEventBus() val controller = MenuController(backend, bus) controller.initialize() fun post(intent: MenuIntent) { bus.postIntent(intent); controller.pumpIntents() } post(MenuIntent.OpenSaveGame) - post(MenuIntent.SaveGameRequested("default", "save0")) + post(MenuIntent.SaveGameRequested("default", "autosave")) assertNull(bus.latestState().savedGames.pendingOverwriteSlot) post(MenuIntent.SaveGameRequested("default", "manual")) assertEquals("manual", bus.latestState().savedGames.pendingOverwriteSlot) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index f403e824..4ff88ac0 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -646,7 +646,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) // copy off the level to the autosave slot if (!Globals.dedicated.typedValue && !game.isDeathmatch) { game.SV_WriteServerFile(true) - ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "autosave") } // multi instance preview: @@ -1621,7 +1621,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) games[replacement.name] = replacement if (!Globals.dedicated.typedValue && !replacement.isDeathmatch) { replacement.SV_WriteServerFile(true) - ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "autosave") } OperationResult.Completed } catch (failure: Exception) { diff --git a/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java index e1627833..42247b46 100644 --- a/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java +++ b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java @@ -62,9 +62,9 @@ void storesKeepIndependentRootsWhenOperationsAreInterleaved() throws Exception { void initialAutosaveDoesNotRequireArchivedLevels() throws Exception { write(temp, "current", "server_mapcmd.ssv.json", "map"); ServerSaveFiles files = new ServerSaveFiles(temp); - files.copy("current", "save0"); + files.copy("current", "autosave"); files.wipe("missing"); - assertEquals("map", Files.readString(temp.resolve("save/save0/server_mapcmd.ssv.json"))); + assertEquals("map", Files.readString(temp.resolve("save/autosave/server_mapcmd.ssv.json"))); } @Test diff --git a/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt index e8c2ae2c..233f07a5 100644 --- a/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt @@ -34,7 +34,7 @@ class ServerSaveCatalogTest { fun `catalog sorts checkpoints retains autosave and isolates malformed entries`() { metadata("current", 5000) metadata("console checkpoint", 1000) - metadata("save0", 3000) + metadata("autosave", 3000) metadata("coop", 2000, coop = true) metadata("deathmatch", 2000, deathmatch = true) metadata("broken", 4000) @@ -43,10 +43,10 @@ class ServerSaveCatalogTest { Files.writeString(root.resolve("save/readme.txt"), "not a slot") val entries = ServerSaveCatalog(root).list() assertFalse(entries.any { it.slot == "current" || it.slot == "readme.txt" }) - assertEquals(listOf("save0", "coop", "deathmatch", "console checkpoint"), + assertEquals(listOf("autosave", "coop", "deathmatch", "console checkpoint"), entries.filter { it.problem == null }.map { it.slot }) - assertEquals("testbox", entries.first { it.slot == "save0" }.mapName) - assertEquals("Test Box", entries.first { it.slot == "save0" }.mapTitle) + assertEquals("testbox", entries.first { it.slot == "autosave" }.mapName) + assertEquals("Test Box", entries.first { it.slot == "autosave" }.mapTitle) assertEquals(SavedGameMode.COOP, entries.first { it.slot == "coop" }.mode) assertEquals(SavedGameMode.DEATHMATCH, entries.first { it.slot == "deathmatch" }.mode) assertNotNull(entries.first { it.slot == "broken" }.problem) @@ -56,17 +56,17 @@ class ServerSaveCatalogTest { @Test fun `existing saves get map titles from level archives or autosave comments`() { metadata("manual", 1) - metadata("save0", 2) + metadata("autosave", 2) val store = ServerSaveJsonStore.forWriteDir(root.toString()) store.writeMapCommand("manual", "old checkpoint comment", "testbox") - store.writeMapCommand("save0", "Autosave in Entry Title", "testbox") + store.writeMapCommand("autosave", "Autosave in Entry Title", "testbox") val strings = arrayOfNulls(jake2.qcommon.Defines.MAX_CONFIGSTRINGS) strings[jake2.qcommon.Defines.CS_NAME] = "Archived Title" jake2.server.save.ServerLevelJsonStore.forWriteDir(root.toString()) .writeLevelState("manual", "testbox", strings, BooleanArray(0)) val entries = ServerSaveCatalog(root).list().associateBy { it.slot } assertEquals("Archived Title", entries.getValue("manual").mapTitle) - assertEquals("Entry Title", entries.getValue("save0").mapTitle) + assertEquals("Entry Title", entries.getValue("autosave").mapTitle) } @Test From a47724b267f5a1d12235cb7f54b48514a6698c5d Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 16:43:52 +0100 Subject: [PATCH 15/57] Capture and display thumbnails for manual menu saves --- cake/README.md | 6 ++- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 20 ++++++- .../demoth/cake/fullgame/SavePreviewStore.kt | 35 ++++++++++++ .../jake2/server/CakeSaveMenusGlTest.kt | 20 +++++++ .../jake2/server/EmbeddedCakeSavesTest.kt | 44 ++++++++++++++- .../jake2/server/GameContinuationTest.kt | 8 +-- .../server/HostSessionPreparationTest.kt | 23 ++++---- .../src/main/kotlin/org/demoth/cake/Cake.kt | 24 +++++++-- .../org/demoth/cake/hosting/CakeHost.kt | 7 ++- .../org/demoth/cake/hosting/HostSaves.kt | 2 +- .../org/demoth/cake/stages/MenuStages.kt | 12 ++++- .../kotlin/org/demoth/cake/stages/README.md | 2 +- .../org/demoth/cake/stages/SavedGamesStage.kt | 53 ++++++++++++++++++- .../demoth/cake/stages/ingame/Game3dScreen.kt | 4 +- .../kotlin/org/demoth/cake/ui/SavePreview.kt | 24 +++++++++ .../org/demoth/cake/ui/menu/MenuController.kt | 2 +- .../org/demoth/cake/ui/MenuStagesGlTest.kt | 24 ++++++++- .../org/demoth/cake/ui/SavePreviewTest.kt | 42 +++++++++++++++ .../demoth/cake/ui/menu/MenuControllerTest.kt | 16 +++++- .../jake2/server/save/ServerSaveFiles.java | 12 ++++- .../server/save/ServerSaveFilesTest.java | 12 ++++- 21 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/SavePreviewStore.kt create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/ui/SavePreview.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/ui/SavePreviewTest.kt diff --git a/cake/README.md b/cake/README.md index a84914c4..bdf0e88f 100644 --- a/cake/README.md +++ b/cake/README.md @@ -139,7 +139,11 @@ with each action enabled when eligible; Back from these screens returns there. Escape goes back through the menus and returns to gameplay from the main menu. Saved games refresh automatically whenever Load Game or Save Game is opened. Each screen shows a scrolling checkpoint list alongside preview space and save -details, with actions along the bottom. Preview thumbnails are not captured yet. +details, with actions along the bottom. Manual saves from the menu include a +thumbnail of the scene without menus (up to 320×240). Missing or unreadable +thumbnails show a placeholder; image capture/write failures leave the save usable +and display a warning. Autosaves and console saves do not capture thumbnails. +Replacing a checkpoint clears its old contents, including any previous thumbnail. New Save creates a checkpoint; Save asks before replacing the selected manual save. Delete asks before removing the selected checkpoint. Autosave is listed for loading and reserved against manual overwrite. Save requires a living, diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 845cacba..142fab4a 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -1,6 +1,7 @@ package org.demoth.cake.fullgame import jake2.qcommon.Defines +import jake2.qcommon.Com import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.CvarTypes @@ -228,7 +229,7 @@ class EmbeddedCakeHost( } } - override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { + override fun saveGame(slot: String?, overwrite: Boolean, previewPng: ByteArray?): HostSaveResult { checkThread() saveAccess().saveRejectionReason?.let { return HostSaveResult.Rejected(it) } saveStorageRejection()?.let { return HostSaveResult.Rejected(it) } @@ -242,7 +243,22 @@ class EmbeddedCakeHost( if (!Files.isDirectory(path, NOFOLLOW_LINKS)) return HostSaveResult.Rejected("Invalid save directory.") if (!overwrite) return HostSaveResult.Rejected("Confirm replacing this saved game.") } - return checkNotNull(server).saveGame(destination).toHostSaveResult(destination) + val result = checkNotNull(server).saveGame(destination).toHostSaveResult(destination) + if (result is HostSaveResult.Completed && previewPng != null) { + try { + SavePreviewStore(profileRoot(checkNotNull(activeProfile))).write(destination, previewPng) + } catch (failure: Exception) { + Com.Warn("Saved checkpoint, but preview failed: ${failure.message}\n") + return result.copy(warning = "Preview could not be saved.") + } + } + return result + } + + override fun readSavePreview(slot: String): ByteArray? { + checkThread() + if (saveContextRejection() != null) return null + return SavePreviewStore(profileRoot(checkNotNull(activeProfile))).read(slot) } override fun loadGame(slot: String): HostSaveResult { diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/SavePreviewStore.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/SavePreviewStore.kt new file mode 100644 index 00000000..620ea6b6 --- /dev/null +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/SavePreviewStore.kt @@ -0,0 +1,35 @@ +package org.demoth.cake.fullgame + +import jake2.server.save.ServerSaveJsonStore +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path + +/** Optional image sidecars, separate from the gameplay working save and its copy/restore flow. */ +internal class SavePreviewStore(private val root: Path) { + private val maxBytes = 1024 * 1024L + + fun write(slot: String, png: ByteArray) { + val image = path(slot) + require(slot != "autosave") { "Autosave capture is not supported." } + require(png.isNotEmpty() && png.size <= maxBytes) { "Invalid preview size." } + require(Files.isDirectory(image.parent, NOFOLLOW_LINKS)) { "Checkpoint no longer exists." } + try { + Files.write(image, png) + } catch (failure: Exception) { + try { Files.deleteIfExists(image) } catch (cleanup: Exception) { failure.addSuppressed(cleanup) } + throw failure + } + } + + fun read(slot: String): ByteArray? = try { + val image = path(slot) + if (Files.isDirectory(image.parent, NOFOLLOW_LINKS) && Files.isRegularFile(image, NOFOLLOW_LINKS) && + Files.size(image) in 1..maxBytes) Files.readAllBytes(image) else null + } catch (_: Exception) { null } + + private fun path(slot: String): Path { + require(slot == ServerSaveJsonStore.normalizeSlot(slot) && slot != "current") { "Invalid checkpoint." } + return root.resolve("save").resolve(slot).resolve("preview.png") + } +} diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt index 75dc31d9..9ad8abd7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt @@ -3,6 +3,8 @@ package jake2.server import com.badlogic.gdx.ApplicationAdapter import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration import com.badlogic.gdx.scenes.scene2d.InputEvent @@ -83,6 +85,19 @@ class CakeSaveMenusGlTest { } private fun next() { step++; ticks = 0 } private fun checkpoint() = temp.resolve(".cake/first/save/$slot/game.ssv.json") + private fun verifyPreview() { + val image = checkpoint().parent.resolve("preview.png") + assertTrue(Files.isRegularFile(image)) + val bytes = Files.readAllBytes(image) + val pixels = Pixmap(bytes, 0, bytes.size) + try { + assertEquals(320, pixels.width) + assertEquals(240, pixels.height) + } finally { pixels.dispose() } + assertNotNull(stage().root.findActor("save-preview-image").drawable) + assertFalse(Files.exists(temp.resolve(".cake/first/save/current/preview.png"))) + assertFalse(Files.exists(temp.resolve(".cake/first/save/autosave/preview.png"))) + } private fun blocker() = temp.resolve(".cake/first/save/current/game.ssv.json") private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } @@ -115,6 +130,7 @@ class CakeSaveMenusGlTest { 3 -> { assertEquals("Game saved.", state().statusMessage) slot = state().selectedSlot!! + verifyPreview() originalSave = Files.readString(checkpoint()) player().health = 41 click("save-overwrite") @@ -135,6 +151,7 @@ class CakeSaveMenusGlTest { 7 -> { assertEquals("Game saved.", state().statusMessage) assertNotEquals(originalSave, Files.readString(checkpoint())) + verifyPreview() player().health = 5 Cmd.ExecuteString("menu_loadgame") next() @@ -191,6 +208,7 @@ class CakeSaveMenusGlTest { } 15 -> { assertEquals("second", state().profileId) + assertNull(stage().root.findActor("save-preview-image").drawable) assertTrue(state().entries.isEmpty()) assertTrue(button("load-game").isDisabled) Cmd.ExecuteString("cake_profile_set first \"$content\"") @@ -200,6 +218,7 @@ class CakeSaveMenusGlTest { 16 -> { assertEquals("first", state().profileId) assertTrue(state().entries.any { it.slot == slot }) + verifyPreview() click("load-game") next() } @@ -230,6 +249,7 @@ class CakeSaveMenusGlTest { assertEquals("Saved game deleted.", state().statusMessage) assertEquals(listOf(slot), state().entries.filter { !it.autosave }.map { it.slot }) assertTrue(Files.exists(checkpoint())) + verifyPreview() writeScreenshot(Gdx.files.local("build/reports/save-menu-layout.png")) Gdx.app.exit() } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index f8e41900..fa107ca0 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -169,7 +169,7 @@ class EmbeddedCakeSavesTest { assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint")) val manager = host.server!! val live = manager.games.getValue("default") - val blocked = root.resolve("first/save/checkpoint/game.ssv.json") + val blocked = root.resolve("first/save/current/server_mapcmd.ssv.json") Files.delete(blocked) Files.createDirectory(blocked) Files.writeString(blocked.resolve("blocker"), "do not replace") @@ -190,6 +190,48 @@ class EmbeddedCakeSavesTest { assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) } + @Test + fun `manual previews follow profile slots and never enter the working save`() { + start() + spawn() + val png = byteArrayOf(1, 2, 3) + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint", previewPng = png)) + assertArrayEquals(png, host.readSavePreview("checkpoint")) + assertNull(host.readSavePreview("autosave")) + assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) + assertFalse(Files.exists(root.resolve("first/save/current/preview.png"))) + assertArrayEquals(png, host.readSavePreview("checkpoint")) + host.stop("switch") + host.activateProfile(profile.copy(id = "second")) + assertNull(host.readSavePreview("checkpoint")) + assertNull(host.readSavePreview("../first")) + host.activateProfile(profile) + assertArrayEquals(png, host.readSavePreview("checkpoint")) + assertEquals(HostSaveResult.Completed("checkpoint"), host.deleteSave("checkpoint")) + assertNull(host.readSavePreview("checkpoint")) + assertFalse(Files.exists(root.resolve("first/save/checkpoint"))) + } + + @Test + fun `overwrite clears previews and preview failure does not fail the checkpoint`() { + start() + spawn() + val live = host.server!!.games.getValue("default") + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint", previewPng = byteArrayOf(1))) + val oversized = ByteArray(1024 * 1024 + 1) + val result = assertInstanceOf(HostSaveResult.Completed::class.java, host.saveGame("checkpoint", true, oversized)) + assertNotNull(result.warning) + assertSame(live, host.server!!.games["default"]) + assertNull(host.readSavePreview("checkpoint")) + assertEquals(HostSaveResult.Completed("checkpoint"), host.loadGame("checkpoint")) + spawn() + assertEquals(HostSaveResult.Completed("checkpoint"), host.saveGame("checkpoint", true, byteArrayOf(2))) + assertArrayEquals(byteArrayOf(2), host.readSavePreview("checkpoint")) + // Console/backend saves have no capture, but must not retain the old image. + assertEquals(ServerHost.OperationResult.Completed, host.server!!.saveGame("checkpoint")) + assertNull(host.readSavePreview("checkpoint")) + } + @Test fun `deleting a checkpoint is profile scoped and leaves the loaded Game running`() { start() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 390b34d0..0b1e4182 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -381,12 +381,12 @@ class GameContinuationTest { assertTrue(NET.configureServer(true)) val broken = when (stage) { "wipe" -> root.resolve("save/current/game.ssv.json") - "autosave" -> root.resolve("save/autosave/game.ssv.json") + "autosave" -> root.resolve("save/autosave") else -> root.resolve("save/current/testbox.sav.json") } - if (stage == "restore") { + if (stage == "restore" || stage == "autosave") { Files.createDirectories(broken.parent) - Files.writeString(broken, "invalid level") + Files.writeString(broken, "invalid level or checkpoint directory") } else { Files.createDirectories(broken) Files.writeString(broken.resolve("blocker"), "prevent replacement") @@ -401,7 +401,7 @@ class GameContinuationTest { assertTrue(host.games.isEmpty(), "Remaining queued continuation must not restart a Game") if (stage != "restore") { - Files.delete(broken.resolve("blocker")) + if (stage != "autosave") Files.delete(broken.resolve("blocker")) Files.delete(broken) } assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 916844d6..cd12249c 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -450,10 +450,9 @@ class HostSessionPreparationTest { @ValueSource(booleans = [false, true]) fun `map autosave failure leaves idle Host and permits restart`(newSession: Boolean) { save(false) - val blocked = root.resolve("save/autosave/game.ssv.json") - Files.delete(blocked) - Files.createDirectory(blocked) - Files.writeString(blocked.resolve("blocker"), "prevent replacement") + val blocked = root.resolve("save/autosave") + jake2.server.save.deleteSaveSlot(root, "autosave") + Files.writeString(blocked, "not a checkpoint directory") val result = if (newSession) host.map("testbox2") else host.changeMap("testbox2") assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) assertTrue(host.games.isEmpty()) @@ -462,7 +461,6 @@ class HostSessionPreparationTest { assertNull(NET.ip_sockets[Defines.NS_SERVER]) assertEquals(ServerStates.SS_DEAD, Globals.server_state) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) - Files.delete(blocked.resolve("blocker")) Files.delete(blocked) assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) } @@ -568,11 +566,16 @@ class HostSessionPreparationTest { save(true) val game = host.games.getValue("default") val client = host.clients[0] - val blocked = if (stage == "destination") root.resolve("save/slot/game.ssv.json") + val blocked = if (stage == "destination") root.resolve("save/slot") else root.resolve("save/current/$stage") - Files.delete(blocked) - Files.createDirectory(blocked) - Files.writeString(blocked.resolve("blocker"), "prevent replacement") + if (stage == "destination") { + jake2.server.save.deleteSaveSlot(root, "slot") + Files.writeString(blocked, "not a checkpoint directory") + } else { + Files.delete(blocked) + Files.createDirectory(blocked) + Files.writeString(blocked.resolve("blocker"), "prevent replacement") + } val result = host.saveGame("slot") assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) assertSame(game, host.games["default"]) @@ -580,7 +583,7 @@ class HostSessionPreparationTest { assertTrue(host.isRunning) assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) host.update(100) - Files.delete(blocked.resolve("blocker")) + if (stage != "destination") Files.delete(blocked.resolve("blocker")) Files.delete(blocked) assertEquals(ServerHost.OperationResult.Completed, host.saveGame("slot")) assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) 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 5c530e58..a54b4641 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -56,6 +56,7 @@ import org.demoth.cake.ui.GameUiStyleFactory import org.demoth.cake.ui.UiViewport import org.demoth.cake.ui.sample.UiSamplePreview import org.demoth.cake.ui.writeScreenshot +import org.demoth.cake.ui.encodeSavePreview import org.demoth.cake.ui.menu.* import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -238,6 +239,9 @@ class Cake( val initialStyle = resolveCurrentGameUiStyle() menuStages = MenuStages(uiViewport, menuEventBus, assetManager, fileResolver, showHosting = host.availability != HostAvailability.REMOTE_ONLY, + readSavePreview = { profileId, slot -> + if (activeGameProfile?.id == profileId) host.readSavePreview(slot) else null + }, activeProfileId = { activeGameProfile?.id }) rebuildContentStyledMenuStages(initialStyle) // todo: gather all early logging (which is generated before the console is created) @@ -722,9 +726,12 @@ class Cake( } if (uiSample.isOpen) { + menuStages.hideSavedGamePreviews() uiSample.render(deltaSeconds) } else if (menuVisible) { menuStages.render(deltaSeconds) + } else { + menuStages.hideSavedGamePreviews() } // Engine console and diagnostics stay in unscaled window coordinates. @@ -1306,7 +1313,7 @@ class Cake( * This is used as a light-weight visual bridge during level transitions. */ private fun captureTransitionBackdropFromActiveScreen() { - val scenePixmap = game3dScreen?.captureScenePixmapForTransition() ?: return + val scenePixmap = game3dScreen?.captureScenePixmap() ?: return transitionBackdropTexture?.dispose() val snapshotTexture = Texture(scenePixmap) scenePixmap.dispose() @@ -1590,8 +1597,19 @@ class Cake( private fun createMenuBackend(): MenuBackend = object : MenuBackend { override fun saveAccess(): HostSaveAccess = host.saveAccess() override fun listSaves(): HostSaveCatalogResult = host.listSaves() - override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult = - finishSaveOperation(host.saveGame(slot, overwrite)) + override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { + val preview = try { + val image = if (localConnection && networkState == ACTIVE && !transitionBackdropActive && + game3dScreen?.canRenderPresentationFrame() == true) game3dScreen?.captureScenePixmap() else null + image?.let { try { encodeSavePreview(it) } finally { it.dispose() } } + } catch (failure: Exception) { + Com.Warn("Could not capture save preview: ${failure.message}\n") + null + } + val result = finishSaveOperation(host.saveGame(slot, overwrite, preview)) + return if (result is HostSaveResult.Completed && preview == null) + result.copy(warning = "Preview unavailable.") else result + } override fun loadGame(slot: String): HostSaveResult = finishSaveOperation(host.loadGame(slot)) override fun deleteSave(slot: String): HostSaveResult = host.deleteSave(slot) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 1b328bef..03f14836 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -29,7 +29,9 @@ interface CakeHost { fun saveAccess(): HostSaveAccess fun listSaves(): HostSaveCatalogResult /** Null creates a generated slot; replacing an existing manual slot requires overwrite. */ - fun saveGame(slot: String? = null, overwrite: Boolean = false): HostSaveResult + fun saveGame(slot: String? = null, overwrite: Boolean = false, previewPng: ByteArray? = null): HostSaveResult + /** Optional encoded image for the selected checkpoint; failure/missing preview returns null. */ + fun readSavePreview(slot: String): ByteArray? fun loadGame(slot: String): HostSaveResult /** Delete one selected checkpoint after the caller has confirmed the action. */ fun deleteSave(slot: String): HostSaveResult @@ -72,7 +74,8 @@ class UnavailableCakeHost : CakeHost { override fun setPaused(paused: Boolean) = Unit override fun saveAccess() = HostSaveAccess(saveUnavailable, saveUnavailable) override fun listSaves() = HostSaveCatalogResult.Rejected(saveUnavailable) - override fun saveGame(slot: String?, overwrite: Boolean) = HostSaveResult.Rejected(saveUnavailable) + override fun saveGame(slot: String?, overwrite: Boolean, previewPng: ByteArray?) = HostSaveResult.Rejected(saveUnavailable) + override fun readSavePreview(slot: String): ByteArray? = null override fun loadGame(slot: String) = HostSaveResult.Rejected(saveUnavailable) override fun deleteSave(slot: String) = HostSaveResult.Rejected(saveUnavailable) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt index 06720b99..1c586d5c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt @@ -24,7 +24,7 @@ sealed interface HostSaveCatalogResult { /** Completed means backend IO/restore completed; the client handshake may still be pending. */ sealed interface HostSaveResult { - data class Completed(val slot: String) : HostSaveResult + data class Completed(val slot: String, val warning: String? = null) : HostSaveResult data class Rejected(val reason: String) : HostSaveResult data class Failed(val reason: String, val cause: Exception) : HostSaveResult } 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 f2e03d38..79c60130 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 @@ -27,6 +27,7 @@ class MenuStages( private val assetManager: AssetManager, private val fileResolver: CakeFileResolver, private val showHosting: Boolean, + private val readSavePreview: (String, String) -> ByteArray? = { _, _ -> null }, private val activeProfileId: () -> String?, ) : Disposable { private val stages = linkedMapOf() @@ -48,8 +49,8 @@ class MenuStages( for (screen in MenuScreen.entries) { stages[screen] = when (screen) { MenuScreen.MAIN -> MainMenuStage(viewport, menuEventBus, style, showHosting) - MenuScreen.LOAD_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = false) - MenuScreen.SAVE_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = true) + MenuScreen.LOAD_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = false, readPreview = readSavePreview) + MenuScreen.SAVE_GAME -> SavedGamesStage(viewport, menuEventBus, style, saving = true, readPreview = readSavePreview) MenuScreen.PROFILE_EDIT -> ProfileEditStage(viewport, menuEventBus, style) MenuScreen.MULTIPLAYER -> MultiplayerMenuStage(viewport, menuEventBus, style, showHosting) MenuScreen.SINGLEPLAYER -> SingleplayerMenuStage(viewport, menuEventBus, style) @@ -77,6 +78,7 @@ class MenuStages( if (screen == activeScreen) return false if (activeScreen == MenuScreen.CONTENT_BROWSER) contentBrowserStage.hide() if (activeScreen == MenuScreen.PLAYER_SETUP) playerSetupStage.hide() + (activeStage as? SavedGamesStage)?.hidePreview() activeStage.resetMenuInteractionState() activeScreen = screen if (activeScreen == MenuScreen.CONTENT_BROWSER) refreshContentBrowser() @@ -97,6 +99,12 @@ class MenuStages( fun hideContentPreviews() { contentBrowserStage.hide() playerSetupStage.hide() + hideSavedGamePreviews() + } + + fun hideSavedGamePreviews() { + (stages[MenuScreen.LOAD_GAME] as? SavedGamesStage)?.hidePreview() + (stages[MenuScreen.SAVE_GAME] as? SavedGamesStage)?.hidePreview() } override fun dispose() { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md index bf0a1483..2538cbce 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md @@ -11,7 +11,7 @@ The developer console has [its own documentation](console/README.md). |---|---| | `MainMenuStage` | Main navigation and profile/options entry points | | `SingleplayerMenuStage` | New Game, Load Game, Save Game and Back | -| `SavedGamesStage` | Scrolling checkpoint list, placeholder preview/details and confirmed save/delete actions | +| `SavedGamesStage` | Scrolling checkpoint list, selected thumbnail/details and confirmed save/delete actions; owns and releases the preview texture on selection, hide and disposal | | `ProfileEditStage` | Profile selection, creation, autodetection, and editing | | `MultiplayerMenuStage` | Join Game, optional Host Game, and Player Setup | | `HostingSetupStage` | Singleplayer difficulty and multiplayer host settings | diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt index 31ec6766..38ff64db 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -1,14 +1,21 @@ package org.demoth.cake.stages import com.badlogic.gdx.Input +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Cell import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Stack import com.badlogic.gdx.scenes.scene2d.ui.List as SceneList import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable +import com.badlogic.gdx.utils.Scaling import com.badlogic.gdx.utils.viewport.Viewport import org.demoth.cake.hosting.HostSaveEntry import org.demoth.cake.ui.GameUiStyle @@ -23,6 +30,7 @@ class SavedGamesStage( menuEventBus: MenuEventBus, private val style: GameUiStyle, private val saving: Boolean, + private val readPreview: (String, String) -> ByteArray? = { _, _ -> null }, ) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenSingleplayerMenu) { private data class Choice(val entry: HostSaveEntry) { override fun toString(): String { @@ -47,6 +55,12 @@ class SavedGamesStage( setFlickScroll(false) } private val detail = Label("", style.menuWidgets.label).apply { setWrap(true) } + private val previewImage = Image().apply { name = "save-preview-image"; setScaling(Scaling.fit) } + private val noPreview = Label("No preview available", style.menuWidgets.label).apply { setAlignment(com.badlogic.gdx.utils.Align.center) } + private var previewTexture: Texture? = null + private var previewEntries: List? = null + private var previewProfile: String? = null + private var previewSlot: String? = null private val status = Label("", style.menuWidgets.label).apply { name = "save-status"; setWrap(true) } private fun action(label: String, name: String, run: () -> Unit) = createMenuButton(label, style.menuWidgets.button, run).apply { @@ -91,7 +105,7 @@ class SavedGamesStage( val preview = Table().apply { background = style.menuWidgets.controlPanel name = "save-preview" - add(Label("No preview available", style.menuWidgets.label)).expand().center() + add(Stack(previewImage, noPreview)).grow().pad(4f) } val right = Table().apply { top() @@ -141,6 +155,26 @@ class SavedGamesStage( listScroll.scrollTo(0f, slots.height - (slots.selectedIndex + 1) * slots.itemHeight, 0f, slots.itemHeight) } val selected = state.entries.firstOrNull { it.slot == state.selectedSlot } + if (previewEntries !== state.entries || previewProfile != state.profileId || previewSlot != selected?.slot) { + hidePreview() + previewEntries = state.entries + previewProfile = state.profileId + previewSlot = selected?.slot + try { + val png = selected?.let { readPreview(state.profileId, it.slot) } + if (png != null) { + val pixels = Pixmap(png, 0, png.size) + try { + require(pixels.width <= 320 && pixels.height <= 240) { "Oversized checkpoint preview" } + previewTexture = Texture(pixels).apply { setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear) } + previewImage.drawable = TextureRegionDrawable(previewTexture) + } finally { pixels.dispose() } + } + } catch (failure: Exception) { + Gdx.app.error("Saved games", "Could not load checkpoint preview", failure) + } + noPreview.isVisible = previewTexture == null + } val pending = state.pendingOverwriteSlot != null || state.pendingDeleteSlot != null val reason = if (saving) snapshot.saveAccess.saveRejectionReason else snapshot.saveAccess.loadRejectionReason profile.setText("Profile: ${state.profileId.ifBlank { "" }}") @@ -171,4 +205,21 @@ class SavedGamesStage( } return super.keyUp(keycode) } + + /** Only the selected preview is retained; hide/rebuild/profile changes release its texture. */ + fun hidePreview() { + if (previewTexture == null && previewEntries == null) return + previewImage.drawable = null + previewTexture?.dispose() + previewTexture = null + previewEntries = null + previewProfile = null + previewSlot = null + noPreview.isVisible = true + } + + override fun dispose() { + hidePreview() + super.dispose() + } } 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 b8f1e2d8..4d8e79ec 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 @@ -353,10 +353,10 @@ class Game3dScreen( /** * Captures the latest scene framebuffer content as a pixmap. * - * Intended for short-lived transition backdrops owned by outer runtime (`Cake`). + * Used for transition backdrops and checkpoint previews owned by outer runtime (`Cake`). * Caller owns and must dispose the returned pixmap. */ - fun captureScenePixmapForTransition(): Pixmap? { + fun captureScenePixmap(): Pixmap? { val frameBuffer = sceneFrameBuffer?.resolved ?: return null frameBuffer.begin() return try { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/SavePreview.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/SavePreview.kt new file mode 100644 index 00000000..8a0abda2 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/SavePreview.kt @@ -0,0 +1,24 @@ +package org.demoth.cake.ui + +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.PixmapIO +import java.io.ByteArrayOutputStream +import kotlin.math.min + +/** Borrow a GL-origin scene image; return an upright PNG bounded to 320x240, without upscaling. */ +fun encodeSavePreview(scene: Pixmap): ByteArray { + val scale = min(1f, min(320f / scene.width, 240f / scene.height)) + val image = Pixmap((scene.width * scale).toInt().coerceAtLeast(1), + (scene.height * scale).toInt().coerceAtLeast(1), Pixmap.Format.RGB888) + val encoder = PixmapIO.PNG() + try { + image.setBlending(Pixmap.Blending.None) + image.setFilter(Pixmap.Filter.BiLinear) + image.drawPixmap(scene, 0, 0, scene.width, scene.height, 0, 0, image.width, image.height) + encoder.setFlipY(true) + return ByteArrayOutputStream().use { output -> encoder.write(output, image); output.toByteArray() } + } finally { + encoder.dispose() + image.dispose() + } +} 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 662d709c..cb3c9dfd 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 @@ -462,7 +462,7 @@ class MenuController( when (result) { is HostSaveResult.Completed -> { refreshSavedGames(result.slot) - saveStatus(if (loading) "Loading game..." else "Game saved.") + saveStatus(if (loading) "Loading game..." else listOfNotNull("Game saved.", result.warning).joinToString(" ")) if (loading) bus.postSignal(MenuSignal.CloseMenu) } is HostSaveResult.Rejected -> saveStatus(result.reason) 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 a7ef4611..501de06e 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 @@ -159,7 +159,15 @@ class MenuStagesGlTest { val bus = MenuEventBus(MenuStateSnapshot(activeScreen = MenuScreen.SAVE_GAME, saveAccess = HostSaveAccess(null, null), savedGames = SavedGamesState("profile", entries, "autosave"))) - val stage = SavedGamesStage(viewport, bus, style, saving = true) + val pixels = com.badlogic.gdx.graphics.Pixmap(32, 24, com.badlogic.gdx.graphics.Pixmap.Format.RGB888) + pixels.setColor(com.badlogic.gdx.graphics.Color.RED) + pixels.fill() + val png = try { encodeSavePreview(pixels) } finally { pixels.dispose() } + var reads = 0 + val stage = SavedGamesStage(viewport, bus, style, saving = true, readPreview = { _, slot -> + reads++ + when (slot) { "manual" -> png; "manual-1" -> byteArrayOf(1, 2, 3); else -> null } + }) try { stage.act(0f) stage.draw() @@ -182,6 +190,16 @@ class MenuStagesGlTest { stage.draw() assertTrue(primary.isDisabled) assertTrue(overwrite.isDisabled) + val image = stage.root.findActor("save-preview-image") + assertNotNull(image.drawable) + val previousReads = reads + stage.act(0f) + assertEquals(previousReads, reads, "Unchanged selection must not reload the image") + stage.hidePreview() + assertNull(image.drawable) + stage.act(0f) + assertNotNull(image.drawable) + assertEquals(previousReads + 1, reads) val confirm = stage.root.findActor("confirm-overwrite") val position = confirm.localToStageCoordinates(com.badlogic.gdx.math.Vector2()) assertTrue(confirm.width > 0 && confirm.height > 0) @@ -201,6 +219,10 @@ class MenuStagesGlTest { deleteConfirm.listeners.filterIsInstance().forEach { it.clicked(InputEvent(), 0f, 0f) } bus.drainIntents { intents += it } assertEquals(MenuIntent.ConfirmDeleteSave("profile", "manual"), intents.last()) + bus.postState(bus.latestState().copy(savedGames = SavedGamesState("profile", entries, "manual-1"))) + stage.act(0f) + assertNull(image.drawable, "Corrupt preview falls back to placeholder") + assertFalse(overwrite.isDisabled, "A corrupt preview must not disable checkpoint actions") bus.postState(bus.latestState().copy(savedGames = SavedGamesState("other"))) stage.act(0f) assertTrue(overwrite.isDisabled) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/SavePreviewTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/SavePreviewTest.kt new file mode 100644 index 00000000..fe9ff225 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/SavePreviewTest.kt @@ -0,0 +1,42 @@ +package org.demoth.cake.ui + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.utils.GdxNativesLoader +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SavePreviewTest { + @Test + fun `scene capture becomes an upright bounded PNG`() { + GdxNativesLoader.load() + val scene = Pixmap(640, 480, Pixmap.Format.RGB888) + try { + scene.setColor(Color.RED); scene.fillRectangle(0, 0, 640, 240) + scene.setColor(Color.BLUE); scene.fillRectangle(0, 240, 640, 240) + val png = encodeSavePreview(scene) + val decoded = Pixmap(png, 0, png.size) + try { + assertEquals(320, decoded.width) + assertEquals(240, decoded.height) + assertEquals(Color.rgba8888(Color.BLUE), decoded.getPixel(10, 10)) + assertEquals(Color.rgba8888(Color.RED), decoded.getPixel(10, 230)) + assertEquals(640, scene.width, "Encoding borrows the source") + } finally { decoded.dispose() } + } finally { scene.dispose() } + } + + @Test + fun `preview preserves aspect ratio and does not upscale small captures`() { + GdxNativesLoader.load() + for ((source, expected) in listOf((640 to 360) to (320 to 180), (480 to 640) to (180 to 240), + (32 to 24) to (32 to 24))) { + val scene = Pixmap(source.first, source.second, Pixmap.Format.RGB888) + try { + val png = encodeSavePreview(scene) + val decoded = Pixmap(png, 0, png.size) + try { assertEquals(expected, decoded.width to decoded.height) } finally { decoded.dispose() } + } finally { scene.dispose() } + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt index 103257c9..239c91e7 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/menu/MenuControllerTest.kt @@ -40,6 +40,19 @@ class MenuControllerTest { private fun savedEntry(slot: String, reason: String? = null) = HostSaveEntry(slot, "Checkpoint", "base1", 1000L, HostMode.SINGLEPLAYER, reason) + @Test + fun successfulSaveWithMissingPreviewShowsWarningAsSuccess() { + val backend = FakeMenuBackend().apply { saveWarning = "Preview unavailable." } + val bus = MenuEventBus() + val controller = MenuController(backend, bus) + controller.initialize() + bus.postIntent(MenuIntent.OpenSaveGame) + bus.postIntent(MenuIntent.SaveGameRequested("default")) + controller.pumpIntents() + assertEquals("Game saved. Preview unavailable.", bus.latestState().savedGames.statusMessage) + assertEquals(MenuScreen.SAVE_GAME, bus.latestState().activeScreen) + } + @Test fun deletionRequiresCurrentProfileConfirmationAndSelectsTheNextCheckpoint() { val backend = FakeMenuBackend().apply { saves = listOf(savedEntry("one"), savedEntry("two"), savedEntry("three")) } @@ -644,6 +657,7 @@ class MenuControllerTest { var loadOutcome: HostSaveResult = HostSaveResult.Completed("manual") val loads = mutableListOf() val savesWritten = mutableListOf>() + var saveWarning: String? = null val deletedSaves = mutableListOf() var deleteOutcome: HostSaveResult? = null override fun deleteSave(slot: String): HostSaveResult { @@ -659,7 +673,7 @@ class MenuControllerTest { } override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { savesWritten += slot to overwrite - return HostSaveResult.Completed(slot ?: "new") + return HostSaveResult.Completed(slot ?: "new", saveWarning) } override fun loadGame(slot: String): HostSaveResult { loads += slot diff --git a/server/src/main/java/jake2/server/save/ServerSaveFiles.java b/server/src/main/java/jake2/server/save/ServerSaveFiles.java index 8b814e0f..f9fbdd5c 100644 --- a/server/src/main/java/jake2/server/save/ServerSaveFiles.java +++ b/server/src/main/java/jake2/server/save/ServerSaveFiles.java @@ -26,6 +26,7 @@ of the License, or (at your option) any later version. import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.LinkOption; import java.util.List; import java.util.Objects; @@ -34,9 +35,11 @@ public final class ServerSaveFiles { private static final List SERVER_FILES = List.of( "server_latched_cvars.ssv.json", "server_mapcmd.ssv.json", "game.ssv.json"); private final Path saveRoot; + private final Path writeRoot; public ServerSaveFiles(Path writeRoot) { - saveRoot = Objects.requireNonNull(writeRoot, "writeRoot").resolve("save"); + this.writeRoot = Objects.requireNonNull(writeRoot, "writeRoot"); + saveRoot = writeRoot.resolve("save"); } /** Remove known save files, leaving unrelated files and the slot directory alone. */ @@ -61,7 +64,12 @@ public void copy(String source, String destination) { if (sourceDirectory.equals(destinationDirectory)) return; try { - wipeDirectory(destinationDirectory); + // Checkpoints own their whole directory. The mutable working save keeps its existing wipe policy. + if (!"current".equals(destination) && Files.exists(destinationDirectory, LinkOption.NOFOLLOW_LINKS)) { + SaveSlotDeletionKt.deleteSaveSlot(writeRoot, destination); + } else { + wipeDirectory(destinationDirectory); + } Files.createDirectories(destinationDirectory); for (String name : SERVER_FILES) { copyIfPresent(sourceDirectory.resolve(name), destinationDirectory.resolve(name)); diff --git a/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java index 42247b46..6dbb7916 100644 --- a/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java +++ b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java @@ -22,10 +22,12 @@ void copiesSaveFilesAndLevelPairsReplacingStaleFiles() throws Exception { write(root, "current", "base1.sv2.json", "portals"); write(root, "current", "orphan.sv2.json", "no matching level"); write(root, "current", "notes.txt", "not a save file"); + write(root, "current", "preview.png", "working preview must not be copied"); write(root, "manual", "game.ssv.json", "longer old game contents"); write(root, "manual", "old.sav.json", "old level"); write(root, "manual", "old.sv2.json", "old portals"); write(root, "manual", "notes.txt", "keep me"); + write(root, "manual", "preview.png", "stale image"); new ServerSaveFiles(root).copy("current", "manual"); @@ -37,7 +39,9 @@ void copiesSaveFilesAndLevelPairsReplacingStaleFiles() throws Exception { assertFalse(Files.exists(root.resolve("save/manual/old.sav.json"))); assertFalse(Files.exists(root.resolve("save/manual/old.sv2.json"))); assertFalse(Files.exists(root.resolve("save/manual/orphan.sv2.json"))); - assertEquals("keep me", Files.readString(root.resolve("save/manual/notes.txt"))); + assertFalse(Files.exists(root.resolve("save/manual/notes.txt"))); + assertFalse(Files.exists(root.resolve("save/manual/preview.png"))); + assertEquals("not a save file", Files.readString(root.resolve("save/current/notes.txt"))); } @Test @@ -86,7 +90,11 @@ void filesystemFailuresAreReportedInsteadOfSilentlyContinuing() throws Exception Files.writeString(blocked.resolve("child"), "prevents deletion"); ServerSaveFiles files = new ServerSaveFiles(temp); assertThrows(UncheckedIOException.class, () -> files.wipe("manual")); - assertThrows(UncheckedIOException.class, () -> files.copy("current", "manual")); + Files.delete(blocked.resolve("child")); + Files.delete(blocked); + Files.delete(blocked.getParent()); + Files.writeString(blocked.getParent(), "not a checkpoint directory"); + assertThrows(IllegalArgumentException.class, () -> files.copy("current", "manual")); } private Path write(Path root, String slot, String name, String content) throws Exception { From 24964d5598cb6e3e5d1b87558ec33d4d843cc9cb Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 17:29:05 +0100 Subject: [PATCH 16/57] fullgame native fix add reachability metadata to be able to host a game in the fullgame client --- cake/README.md | 11 +- .../game/reachability-metadata.json | 328 ++++++++++++++++++ .../qcommon/reachability-metadata.json | 40 +++ .../server/reachability-metadata.json | 28 ++ 4 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 game/src/main/resources/META-INF/native-image/org.demoth/game/reachability-metadata.json create mode 100644 qcommon/src/main/resources/META-INF/native-image/org.demoth/qcommon/reachability-metadata.json create mode 100644 server/src/main/resources/META-INF/native-image/org.demoth/server/reachability-metadata.json diff --git a/cake/README.md b/cake/README.md index bdf0e88f..e169aa06 100644 --- a/cake/README.md +++ b/cake/README.md @@ -96,6 +96,7 @@ experimental native executable: ```sh ./gradlew -PenableGraalNative=true :cake:cake-client:nativeCompile ./gradlew -PenableGraalNative=true :cake:cake-viewer:nativeCompile +./gradlew -PenableGraalNative=true :cake:cake-fullgame:nativeCompile ``` These tasks require GraalVM 25 or newer with Native Image. Set `GRAALVM_HOME` to @@ -103,9 +104,13 @@ that installation; Gradle can continue running on the regular project JDK. Native resources are detected from the runtime classpath, including repository assets and dependency libraries. Ordinary JVM builds and ZIPs omit the Graal helpers. All three launchers use the shared native configuration and explicit reflection -metadata for audio and Scene2D skin/pool classes. Native compilation and basic -startup are verified on Linux ARM64; fullgame hosting and save/load still need -runtime validation. +metadata for audio and Scene2D skin/pool classes. The `game` module supplies +type registrations for its callback preload list; keep these aligned with +`GameExportsImpl.preloadclasslist`. The `qcommon`, `server`, and `game` modules +also register their save DTOs for Jackson reflection. +Native compilation, basic startup, local map transitions, and console save/load +are verified on Linux ARM64. Multiplayer hosting still needs native runtime +validation. ## Playing and hosting diff --git a/game/src/main/resources/META-INF/native-image/org.demoth/game/reachability-metadata.json b/game/src/main/resources/META-INF/native-image/org.demoth/game/reachability-metadata.json new file mode 100644 index 00000000..87399d3b --- /dev/null +++ b/game/src/main/resources/META-INF/native-image/org.demoth/game/reachability-metadata.json @@ -0,0 +1,328 @@ +{ + "reflection": [ + { + "type": "jake2.game.adapters.AIAdapter" + }, + { + "type": "jake2.game.adapters.EntBlockedAdapter" + }, + { + "type": "jake2.game.adapters.EntDieAdapter" + }, + { + "type": "jake2.game.adapters.EntDodgeAdapter" + }, + { + "type": "jake2.game.adapters.EntInteractAdapter" + }, + { + "type": "jake2.game.adapters.EntPainAdapter" + }, + { + "type": "jake2.game.adapters.EntThinkAdapter" + }, + { + "type": "jake2.game.adapters.EntTouchAdapter" + }, + { + "type": "jake2.game.adapters.EntUseAdapter" + }, + { + "type": "jake2.game.adapters.ItemDropAdapter" + }, + { + "type": "jake2.game.adapters.ItemUseAdapter" + }, + { + "type": "jake2.game.adapters.SuperAdapter" + }, + { + "type": "jake2.game.PlayerWeapon" + }, + { + "type": "jake2.game.EdictIterator" + }, + { + "type": "jake2.game.EdictFindFilter" + }, + { + "type": "jake2.game.GameAI" + }, + { + "type": "jake2.game.GameBase" + }, + { + "type": "jake2.game.GameChase" + }, + { + "type": "jake2.game.GameCombat" + }, + { + "type": "jake2.game.GameMisc" + }, + { + "type": "jake2.game.GameSpawn" + }, + { + "type": "jake2.game.GameUtil" + }, + { + "type": "jake2.game.GameWeapon" + }, + { + "type": "jake2.qcommon.Info" + }, + { + "type": "jake2.game.Monster" + }, + { + "type": "jake2.game.PlayerClient" + }, + { + "type": "jake2.game.PlayerHud" + }, + { + "type": "jake2.game.PlayerTrail" + }, + { + "type": "jake2.game.PlayerView" + }, + { + "type": "jake2.game.monsters.M_Berserk" + }, + { + "type": "jake2.game.monsters.M_Hornet" + }, + { + "type": "jake2.game.monsters.M_Makron_Idle" + }, + { + "type": "jake2.game.monsters.M_Makron_Jorg" + }, + { + "type": "jake2.game.monsters.M_Makron" + }, + { + "type": "jake2.game.monsters.M_Brain" + }, + { + "type": "jake2.game.monsters.M_Chick" + }, + { + "type": "jake2.qcommon.M_Flash" + }, + { + "type": "jake2.game.monsters.M_Flipper" + }, + { + "type": "jake2.game.monsters.M_Float" + }, + { + "type": "jake2.game.monsters.M_Flyer" + }, + { + "type": "jake2.game.monsters.M_Gladiator" + }, + { + "type": "jake2.game.monsters.M_Gunner" + }, + { + "type": "jake2.game.monsters.M_Hover" + }, + { + "type": "jake2.game.monsters.M_Infantry" + }, + { + "type": "jake2.game.monsters.M_Insane" + }, + { + "type": "jake2.game.monsters.M_Medic" + }, + { + "type": "jake2.game.monsters.M_Mutant" + }, + { + "type": "jake2.game.monsters.M_Parasite" + }, + { + "type": "jake2.game.monsters.M_Player" + }, + { + "type": "jake2.game.monsters.M_Soldier" + }, + { + "type": "jake2.game.monsters.M_Supertank" + }, + { + "type": "jake2.game.monsters.M_Tank" + }, + { + "type": "jake2.game.items.GameItems" + }, + { + "type": "jake2.game.LightKt" + }, + { + "type": "jake2.game.TriggersKt" + }, + { + "type": "jake2.game.PathKt" + }, + { + "type": "jake2.game.InfoEntitiesKt" + }, + { + "type": "jake2.game.TurretKt" + }, + { + "type": "jake2.game.TargetEntitiesKt" + }, + { + "type": "jake2.game.MiscEntitiesKt" + }, + { + "type": "jake2.game.func.Func_commonKt" + }, + { + "type": "jake2.game.func.Func_objectKt" + }, + { + "type": "jake2.game.func.AreaportalKt" + }, + { + "type": "jake2.game.func.ClockKt" + }, + { + "type": "jake2.game.func.DoorKt" + }, + { + "type": "jake2.game.func.ButtonKt" + }, + { + "type": "jake2.game.func.ConveyorKt" + }, + { + "type": "jake2.game.func.RotatingKt" + }, + { + "type": "jake2.game.func.PlatKt" + }, + { + "type": "jake2.game.func.TrainKt" + }, + { + "type": "jake2.game.func.KillboxKt" + }, + { + "type": "jake2.game.func.WallKt" + }, + { + "type": "jake2.game.func.TimerKt" + }, + { + "type": "jake2.game.func.ExplosiveKt" + }, + { + "type": "jake2.game.GamePlayerSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.GameSaveFileSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.EntityStateSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.MonsterFrameSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.MonsterMoveSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.MonsterInfoSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.LevelLocalsSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.LevelEntitySnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.LevelSaveFileSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.EntityComponentsSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.MoveInfoSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.Medkit", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.Light", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.LightRamp", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.AreaPortal", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.components.Earthquake", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.game.func.MovementState", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + } + ] +} diff --git a/qcommon/src/main/resources/META-INF/native-image/org.demoth/qcommon/reachability-metadata.json b/qcommon/src/main/resources/META-INF/native-image/org.demoth/qcommon/reachability-metadata.json new file mode 100644 index 00000000..91b63f60 --- /dev/null +++ b/qcommon/src/main/resources/META-INF/native-image/org.demoth/qcommon/reachability-metadata.json @@ -0,0 +1,40 @@ +{ + "reflection": [ + { + "type": "jake2.qcommon.save.ClientPersistentSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.qcommon.save.ClientRespawnSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.qcommon.save.GameLocalsSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.qcommon.save.PlayerStateSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.qcommon.save.PmoveStateSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.qcommon.math.Vector3f", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + } + ] +} diff --git a/server/src/main/resources/META-INF/native-image/org.demoth/server/reachability-metadata.json b/server/src/main/resources/META-INF/native-image/org.demoth/server/reachability-metadata.json new file mode 100644 index 00000000..0cca526f --- /dev/null +++ b/server/src/main/resources/META-INF/native-image/org.demoth/server/reachability-metadata.json @@ -0,0 +1,28 @@ +{ + "reflection": [ + { + "type": "jake2.server.save.ServerSaveJsonStore$ServerMapCommandSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.server.save.ServerSaveJsonStore$LatchedCvarSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.server.save.ServerSaveJsonStore$ServerLatchedCvarsSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "jake2.server.save.ServerLevelStateSnapshot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + } + ] +} From 1d85ddd202763c8bfbb9c4d48623e22b61374963 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 22:21:04 +0100 Subject: [PATCH 17/57] Fix network packet size boundary and explicit-IP port fallback --- .../main/java/jake2/qcommon/network/NET.java | 21 ++-- .../network/messages/NetworkPacket.java | 2 +- .../qcommon/network/NetPacketReceiveTest.java | 108 ++++++++++++++++++ .../network/NetSocketOwnershipTest.java | 31 +++++ 4 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 8e585824..50ea4c85 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -126,14 +126,16 @@ public static NetworkPacket receiveNetworkPacket(DatagramSocket socket, Datagram return null; try { - byte[] bytes = new byte[MAX_MSGLEN]; + // Receive one extra byte so oversized datagrams can be detected even + // when DatagramChannel truncates them to the buffer capacity. + byte[] bytes = new byte[MAX_MSGLEN + 1]; ByteBuffer receiveBuffer = ByteBuffer.wrap(bytes); InetSocketAddress srcSocket = (InetSocketAddress) channel.receive(receiveBuffer); if (srcSocket == null) return null; - if (receiveBuffer.position() > MAX_MSGLEN) // how it is possible? + if (receiveBuffer.position() > MAX_MSGLEN) return null; return NetworkPacket.fromSocket(fromClient, bytes, receiveBuffer.position(), srcSocket.getAddress().getAddress(), srcSocket.getPort()); @@ -255,18 +257,13 @@ public static DatagramSocket Socket(int sock, String ip, int port) { if (ip_channels[sock] == null || !ip_channels[sock].isOpen()) ip_channels[sock] = DatagramChannel.open(); + int bindPort = port == Defines.PORT_ANY ? 0 : port; + newsocket = ip_channels[sock].socket(); if (ip == null || ip.length() == 0 || ip.equals("localhost")) { - if (port == Defines.PORT_ANY) { - newsocket = ip_channels[sock].socket(); - newsocket.bind(new InetSocketAddress(0)); - } else { - newsocket = ip_channels[sock].socket(); - newsocket.bind(new InetSocketAddress(port)); - } + newsocket.bind(new InetSocketAddress(bindPort)); } else { InetAddress ia = InetAddress.getByName(ip); - newsocket = ip_channels[sock].socket(); - newsocket.bind(new InetSocketAddress(ia, port)); + newsocket.bind(new InetSocketAddress(ia, bindPort)); } // nonblocking channel @@ -326,4 +323,4 @@ public static void Sleep(int msec) { * select(ip_sockets[NS_SERVER]+1, &fdset, NULL, NULL, &timeout); */ } -} \ No newline at end of file +} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index e323ee39..f0bbc7fc 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -56,7 +56,7 @@ public static NetworkPacket fromSocket(boolean fromClient, byte[] data, int data networkPacket.from.type = NetAddrType.NA_IP; networkPacket.buffer.cursize = datalen; networkPacket.buffer.data = data; - networkPacket.buffer.data[datalen] = 0; // set the sentinel + // Reads are bounded by cursize; the backing array needs no sentinel byte. networkPacket.parseHeader(); return networkPacket; } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java new file mode 100644 index 00000000..4a6c330f --- /dev/null +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -0,0 +1,108 @@ +package jake2.qcommon.network; + +import jake2.qcommon.exec.Cvar; +import jake2.qcommon.network.messages.NetworkPacket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.DatagramChannel; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.util.Arrays; + +import static jake2.qcommon.Defines.MAX_MSGLEN; +import static org.junit.jupiter.api.Assertions.*; + +class NetPacketReceiveTest { + @BeforeEach + void setup() { + Cvar.getInstance().clear(); + Netchan.Netchan_Init(); + } + + @AfterEach + void cleanup() { + Cvar.getInstance().clear(); + } + + @ParameterizedTest + @CsvSource({"1399, false", "1400, false", "1399, true", "1400, true"}) + void receivesWholeDatagramAtSizeBoundary(int length, boolean fromClient) throws Exception { + byte[] bytes = sequencedPacket(length, fromClient); + NetworkPacket packet = receive(bytes, fromClient); + + assertNotNull(packet); + assertEquals(length, packet.buffer.cursize); + assertArrayEquals(bytes, Arrays.copyOf(packet.buffer.data, packet.buffer.cursize)); + assertEquals(7, packet.sequence); + assertEquals(3, packet.sequenceAck); + assertEquals(fromClient ? 42 : 0, packet.qport); + assertEquals(NetAddrType.NA_IP, packet.from.type); + } + + @ParameterizedTest + @ValueSource(ints = {1401, 4096}) + void rejectsOversizedDatagramsInsteadOfAcceptingTruncatedPayload(int length) throws Exception { + assertNull(receive(sequencedPacket(length, false), false)); + } + + @Test + void socketPacketAcceptsAnExactlySizedArray() { + byte[] bytes = sequencedPacket(MAX_MSGLEN, true); + NetworkPacket packet = NetworkPacket.fromSocket(true, bytes, bytes.length, + new byte[]{127, 0, 0, 1}, 27910); + + assertEquals(MAX_MSGLEN, packet.buffer.cursize); + assertEquals(7, packet.sequence); + assertEquals(42, packet.qport); + } + + @Test + void connectionlessStringEndsAtDatagramBoundaryWithoutTerminator() throws Exception { + byte[] bytes = new byte[MAX_MSGLEN]; + Arrays.fill(bytes, (byte) 'x'); + ByteBuffer.wrap(bytes).putInt(-1); + + NetworkPacket packet = receive(bytes, true); + assertNotNull(packet); + assertTrue(packet.isConnectionless()); + assertEquals("x".repeat(MAX_MSGLEN - 4), packet.connectionlessMessage); + + NetworkPacket loopback = NetworkPacket.fromLoopback(true, bytes, bytes.length); + assertEquals(loopback.connectionlessMessage, packet.connectionlessMessage); + } + + private static byte[] sequencedPacket(int length, boolean fromClient) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, (byte) 0x5a); + ByteBuffer header = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + header.putInt(7).putInt(3); + if (fromClient) header.putShort((short) 42); + return bytes; + } + + private static NetworkPacket receive(byte[] bytes, boolean fromClient) throws Exception { + try (var receiver = DatagramChannel.open(); + var sender = DatagramChannel.open(); + var selector = Selector.open()) { + receiver.bind(new InetSocketAddress("127.0.0.1", 0)); + receiver.configureBlocking(false); + receiver.register(selector, SelectionKey.OP_READ); + assertEquals(bytes.length, sender.send(ByteBuffer.wrap(bytes), receiver.getLocalAddress())); + + long deadline = System.nanoTime() + 2_000_000_000L; + while (selector.selectedKeys().isEmpty() && System.nanoTime() < deadline) { + selector.select(100); + } + assertFalse(selector.selectedKeys().isEmpty(), "UDP datagram did not arrive"); + return NET.receiveNetworkPacket(receiver.socket(), receiver, new NET.loopback_t(), fromClient); + } + } +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index 50e2b215..a3d0ecea 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -6,8 +6,12 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; import static jake2.qcommon.Defines.*; import static org.junit.jupiter.api.Assertions.*; @@ -60,6 +64,33 @@ void failedServerBindReleasesChannelAndAllowsRetry() throws Exception { assertTrue(NET.configureServer(true)); } + @ParameterizedTest + @ValueSource(ints = {PORT_ANY, 0}) + void explicitBindAddressSupportsEphemeralPorts(int port) throws Exception { + Cvar.getInstance().find("ip", String.class).forceSet("127.0.0.1"); + Cvar.getInstance().find("port", Integer.class).forceSet(port); + + assertTrue(NET.configureServer(true)); + assertEquals(InetAddress.getByName("127.0.0.1"), NET.ip_sockets[NS_SERVER].getLocalAddress()); + assertTrue(NET.ip_sockets[NS_SERVER].getLocalPort() > 0); + } + + @Test + void occupiedClientPortFallsBackOnExplicitBindAddress() throws Exception { + Cvar.getInstance().find("ip", String.class).forceSet("127.0.0.1"); + try (var occupied = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0))) { + Cvar.getInstance().find("clientport", Integer.class).forceSet(occupied.getLocalPort()); + + assertTrue(NET.configureClient(true)); + var client = NET.ip_sockets[NS_CLIENT]; + assertEquals(occupied.getLocalAddress(), client.getLocalAddress()); + assertTrue(client.getLocalPort() > 0); + assertNotEquals(occupied.getLocalPort(), client.getLocalPort()); + assertTrue(NET.ip_channels[NS_CLIENT].isOpen()); + assertFalse(NET.ip_channels[NS_CLIENT].isBlocking()); + } + } + @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); From 12e015aa0ceff7a20f5d9e7c1c92bd788adf0e55 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 22:42:13 +0100 Subject: [PATCH 18/57] Introduce owned Kotlin datagram endpoints and immutable network values --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 2 +- .../jake2/server/CakeLocalSessionGlTest.kt | 26 +- .../kotlin/jake2/server/CakeSessionPeer.kt | 2 +- .../jake2/server/EmbeddedCakeHostTest.kt | 19 +- .../jake2/server/GameContinuationTest.kt | 6 +- .../jake2/server/HostClientAccountingTest.kt | 6 +- .../server/HostSessionPreparationTest.kt | 40 +-- .../src/main/kotlin/org/demoth/cake/Cake.kt | 7 +- client/src/main/java/jake2/client/CL.java | 6 +- .../jake2/dedicated/DedicatedLifetimeTest.kt | 4 +- .../main/java/jake2/qcommon/network/NET.java | 249 ++++++------------ .../network/transport/DatagramEndpoint.kt | 35 +++ .../qcommon/network/transport/LoopbackLink.kt | 57 ++++ .../network/transport/NetworkAddress.kt | 57 ++++ .../qcommon/network/transport/PacketBytes.kt | 23 ++ .../network/transport/UdpDatagramEndpoint.kt | 71 +++++ .../qcommon/network/NetPacketReceiveTest.java | 40 ++- .../network/NetSocketOwnershipTest.java | 42 ++- .../kotlin/jake2/qcommon/OutputFailureTest.kt | 31 ++- .../network/transport/DatagramEndpointTest.kt | 197 ++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 7 +- 21 files changed, 626 insertions(+), 301 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/DatagramEndpoint.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/LoopbackLink.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/transport/DatagramEndpointTest.kt diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 142fab4a..f5aac080 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -121,7 +121,7 @@ class EmbeddedCakeHost( return HostStartResult.Rejected(result.reason) } } - if (request.mode != HostMode.SINGLEPLAYER && NET.ip_sockets[Defines.NS_SERVER] == null) { + if (request.mode != HostMode.SINGLEPLAYER && NET.udpEndpoint(Defines.NS_SERVER) == null) { stop("Server port unavailable") return HostStartResult.Rejected("Could not open the server UDP port") } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index 629e0368..e4eaf411 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -49,7 +49,7 @@ class CakeLocalSessionGlTest { private var ticks = 0 private var stageStart = System.nanoTime() private var origin = floatArrayOf() - private var serverSocket: java.net.DatagramSocket? = null + private var serverEndpoint: jake2.qcommon.network.transport.UdpDatagramEndpoint? = null private lateinit var clientPeer: Process private lateinit var serverPeer: Process private val clientReady = temp.resolve("client-ready") @@ -84,7 +84,7 @@ class CakeLocalSessionGlTest { ticks++ when (step) { 0 -> if (state(cake) == "ACTIVE") { - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) origin = playerOrigin(host) next("+forward") } @@ -97,7 +97,7 @@ class CakeLocalSessionGlTest { } 2 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2") { assertEquals(HostState.RUNNING, host.state) - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) next("map base1") } 3 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base1") { @@ -115,14 +115,14 @@ class CakeLocalSessionGlTest { 7 -> { assertEquals(HostState.STOPPED, host.state) assertEquals("DISCONNECTED", state(cake)) - assertTrue(NET.ip_channels.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) Cvar.getInstance().find("port").forceSet(0) next("set coop 1; set deathmatch 0; set maxclients 4; map base1") } 8 -> if (state(cake) == "ACTIVE") { - serverSocket = NET.ip_sockets[Defines.NS_SERVER]!! - assertNull(NET.ip_sockets[Defines.NS_CLIENT]) - clientPeer = launchPeer("client", basedir, clientReady, serverSocket!!.localPort) + serverEndpoint = NET.udpEndpoint(Defines.NS_SERVER)!! + assertNull(NET.udpEndpoint(Defines.NS_CLIENT)) + clientPeer = launchPeer("client", basedir, clientReady, serverEndpoint!!.localAddress.port) peers.add(clientPeer) next() } @@ -131,14 +131,14 @@ class CakeLocalSessionGlTest { if (Files.exists(clientReady) && spawnedClients(host) == 2) next("gamemap base2") } 10 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2" && spawnedClients(host) == 2) { - assertSame(serverSocket, NET.ip_sockets[Defines.NS_SERVER]) + assertSame(serverEndpoint, NET.udpEndpoint(Defines.NS_SERVER)) clientPeer.destroy() next("disconnect; set coop 0; set deathmatch 1; set maxclients 8; map base1") } 11 -> if (state(cake) == "ACTIVE") { Files.deleteIfExists(clientReady) clientPeer = launchPeer("client", basedir, clientReady, - NET.ip_sockets[Defines.NS_SERVER]!!.localPort) + NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port) peers.add(clientPeer) next() } @@ -157,19 +157,19 @@ class CakeLocalSessionGlTest { } 14 -> if (state(cake) == "ACTIVE") { assertEquals(HostState.STOPPED, host.state) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) - assertNotNull(NET.ip_sockets[Defines.NS_CLIENT]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(NET.udpEndpoint(Defines.NS_CLIENT)) next("disconnect") } 15 -> { assertEquals("DISCONNECTED", state(cake)) - assertTrue(NET.ip_channels.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) next("reconnect") } 16 -> if (state(cake) == "ACTIVE") next("disconnect") 17 -> { assertEquals("DISCONNECTED", state(cake)) - assertTrue(NET.ip_channels.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) Gdx.app.exit() } } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt index 6876897a..05367566 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt @@ -37,7 +37,7 @@ object CakeSessionPeer { try { host.initialize() check(host.start(HostRequest(profile, "base1", HostMode.DEATHMATCH)) == HostStartResult.Started) - Files.writeString(ready, NET.ip_sockets[Defines.NS_SERVER]!!.localPort.toString()) + Files.writeString(ready, NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port.toString()) var previous = System.nanoTime() while (System.nanoTime() < deadline) { Thread.sleep(5) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index e59dc25f..a2a35985 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -81,7 +81,7 @@ class EmbeddedCakeHostTest { assertEquals(HostState.STOPPED, host.state) assertEquals(previousRoot, EngineWriteRoot.path()) assertFalse(Files.exists(temp.resolve("session"))) - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) } @Test @@ -109,7 +109,7 @@ class EmbeddedCakeHostTest { assertNull(Cmd.FindCommand("load")) assertNull(Cmd.FindCommand("save")) assertEquals(previousRoot, EngineWriteRoot.path()) - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) assertThrows(IllegalStateException::class.java) { host.initialize() } } @@ -188,7 +188,7 @@ class EmbeddedCakeHostTest { host.update(100) assertEquals(HostState.RUNNING, host.state) assertEquals(1, host.server!!.games.getValue("default").serverMain.clients.size) - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) } @Test @@ -237,7 +237,7 @@ class EmbeddedCakeHostTest { assertEquals("", cvars.find("gamedir").typedValue) assertEquals(temp.resolve("session"), EngineWriteRoot.path()) assertTrue(Files.exists(temp.resolve("session/save/autosave/game.ssv.json"))) - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) val frame = game().level.framenum host.update(100) assertEquals(frame + 1, game().level.framenum) @@ -306,14 +306,14 @@ class EmbeddedCakeHostTest { fun `multiplayer continues while menus request pause`() { for (mode in listOf(HostMode.DEATHMATCH, HostMode.COOP)) { assertEquals(HostStartResult.Started, host.start(request(mode))) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) val frame = game().level.framenum host.setPaused(true) host.update(100) assertFalse(cvars.find("paused").typedValue) assertEquals(frame + 1, game().level.framenum) host.stop("next mode") - assertTrue(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) } } @@ -323,13 +323,12 @@ class EmbeddedCakeHostTest { cvars.find("port").forceSet(occupied.localPort) assertInstanceOf(HostStartResult.Rejected::class.java, host.start(request(HostMode.COOP))) assertEquals(HostState.STOPPED, host.state) - assertTrue(NET.ip_sockets.all { it == null }) - assertTrue(NET.ip_channels.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) assertNotNull(Cmd.FindCommand("map")) } assertEquals(HostStartResult.Started, host.start(request(HostMode.COOP))) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) - assertNull(NET.ip_sockets[Defines.NS_CLIENT]) + assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(NET.udpEndpoint(Defines.NS_CLIENT)) } @Test diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 0b1e4182..9d5f6a78 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -193,7 +193,7 @@ class GameContinuationTest { first.gameExports = null // Discarding a Game must not call back into its gameplay. Cvar.getInstance().find("port").forceSet(0) assertTrue(NET.configureServer(true)) - val socket = NET.ip_sockets[Defines.NS_SERVER] + val socket = NET.udpEndpoint(Defines.NS_SERVER) val peerChannel = b.netchan host.stopGame(first, "Game failed\n") @@ -207,7 +207,7 @@ class GameContinuationTest { assertTrue(client.unreliable.isEmpty()) } assertSame(second, host.games["other"]) - assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + assertSame(socket, NET.udpEndpoint(Defines.NS_SERVER)) assertSame(peerChannel, b.netchan) assertEquals(ClientStates.CS_CONNECTED, b.state) host.update(100) @@ -395,7 +395,7 @@ class GameContinuationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("gamemap")) host.update(100) assertTrue(host.games.isEmpty(), "Remaining queued continuation must not restart a Game") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 1b3f2fed..c5fd3fe2 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -77,7 +77,7 @@ class HostClientAccountingTest { private fun drainPackets() { for (side in listOf(Defines.NS_CLIENT, Defines.NS_SERVER)) { - while (NET.receiveNetworkPacket(null, null, NET.loopbacks[side], side == Defines.NS_SERVER) != null) { } + while (NET.receiveLoopbackPacket(side) != null) { } } } @@ -354,7 +354,7 @@ class HostClientAccountingTest { try { val request = byteArrayOf(-1, -1, -1, -1) + "status".toByteArray() receiver.send(java.net.DatagramPacket(request, request.size, loopback, - NET.ip_sockets[Defines.NS_SERVER]!!.localPort)) + NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port)) val response = java.net.DatagramPacket(ByteArray(2048), 2048) // The UDP receive path is nonblocking; allow delivery before pumping it again. val deadline = System.nanoTime() + 2_000_000_000L @@ -385,7 +385,7 @@ class HostClientAccountingTest { // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. val address = netadr_t().apply { port = 12345 } host.handleConnectionlessPacket(address, "getchallenge") - val reply = NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)!!.connectionlessMessage + val reply = NET.receiveLoopbackPacket(Defines.NS_CLIENT)!!.connectionlessMessage val challenge = reply.trim().substringAfterLast(' ') val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" first.frameNum = 9000 diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index cd12249c..166758d6 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -97,7 +97,7 @@ class HostSessionPreparationTest { assertEquals(coop, loaded.isCoop) assertEquals(!coop, cvars.find("coop").typedValue, "Loading must preserve Host mode defaults") assertTrue(host.clients.all { it.gameName == "default" }) - assertEquals(coop, NET.ip_sockets[Defines.NS_SERVER] != null) + assertEquals(coop, NET.udpEndpoint(Defines.NS_SERVER) != null) // Exercise the snapshot ring allocated from the prepared client capacity. host.clients[0].edict = loaded.gameExports.getEdict(1) loaded.buildClientFrameMessages(host.clients[0]) @@ -344,7 +344,7 @@ class HostSessionPreparationTest { host.games[other.name] = other host.clients[1].gameName = other.name cvars.find("paused").forceSet(true) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) if (stage == "idle") { host.stop("test", false) NET.configureServer(false) @@ -375,7 +375,7 @@ class HostSessionPreparationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) assertEquals(ServerStates.SS_DEAD, Globals.server_state) assertEquals("", cvars.find("mapname").typedValue) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("load")) @@ -406,7 +406,7 @@ class HostSessionPreparationTest { assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.loadGame("slot")) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) } cvars.find("port").forceSet(0) assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) @@ -458,7 +458,7 @@ class HostSessionPreparationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) assertEquals(ServerStates.SS_DEAD, Globals.server_state) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) Files.delete(blocked) @@ -493,8 +493,8 @@ class HostSessionPreparationTest { NET.clearLoopbacks() cvars.find("clientport").forceSet(0) assertTrue(NET.configureClient(true)) - val clientSocket = NET.ip_sockets[Defines.NS_CLIENT] - val serverSocket = NET.ip_sockets[Defines.NS_SERVER] + val clientSocket = NET.udpEndpoint(Defines.NS_CLIENT) + val serverEndpoint = NET.udpEndpoint(Defines.NS_SERVER) val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") val client = host.clients[0] client.state = ClientStates.CS_CONNECTED @@ -507,20 +507,20 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.AddText("test_stopped_deferred") jake2.qcommon.exec.Cbuf.CopyToDefer() host.stop("test stop", false) - assertTrue(serverSocket.isClosed) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) - assertSame(clientSocket, NET.ip_sockets[Defines.NS_CLIENT]) - assertFalse(clientSocket.isClosed) + assertFalse(serverEndpoint.isOpen) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertSame(clientSocket, NET.udpEndpoint(Defines.NS_CLIENT)) + assertTrue(clientSocket.isOpen) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) assertEquals("", cvars.find("mapname").typedValue) assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) - assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_SERVER], true)) - assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) - assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + assertNull(NET.receiveLoopbackPacket(Defines.NS_SERVER)) + assertNotNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) + assertNotNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) host.stop("again", false) - assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + assertNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) jake2.qcommon.exec.Cbuf.InsertFromDefer() jake2.qcommon.exec.Cbuf.Execute() assertFalse(deferredRan) @@ -542,7 +542,7 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.Execute() assertEquals("testbox2", host.games.getValue("default").mapName) assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) } @Test @@ -554,7 +554,7 @@ class HostSessionPreparationTest { assertThrows(NullPointerException::class.java) { host.close() } assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(Defines.NS_SERVER)) assertFalse(host.isPaused) assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) host.close() @@ -581,7 +581,7 @@ class HostSessionPreparationTest { assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) assertTrue(host.isRunning) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) host.update(100) if (stage != "destination") Files.delete(blocked.resolve("blocker")) Files.delete(blocked) @@ -643,7 +643,7 @@ class HostSessionPreparationTest { assertNull(EngineVfs.loadBytes("maps.lst"), "Fixture must have no optional map list") val games = host.games.toMap() val clients = host.clients.toList() - val socket = NET.ip_sockets[Defines.NS_SERVER] + val socket = NET.udpEndpoint(Defines.NS_SERVER) val output = StringBuilder() val sink = Com.ConsoleSink { _, message -> output.append(message) } var continued = false @@ -658,7 +658,7 @@ class HostSessionPreparationTest { assertEquals(running, host.isRunning) assertEquals(games, host.games) assertEquals(clients, host.clients.toList()) - assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + assertSame(socket, NET.udpEndpoint(Defines.NS_SERVER)) } finally { Com.ClearConsoleSink(sink) jake2.qcommon.exec.Cmd.RemoveCommand("test_after_listmaps") 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 a54b4641..694737b9 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1102,12 +1102,7 @@ class Cake( private fun CL_ReadPackets() { while (true) { - val networkPacket = NET.receiveNetworkPacket( - NET.ip_sockets[NS_CLIENT], - NET.ip_channels[NS_CLIENT], - NET.loopbacks[NS_CLIENT], - false - ) + val networkPacket = NET.receiveNetworkPacket(NS_CLIENT) if (networkPacket == null) break diff --git a/client/src/main/java/jake2/client/CL.java b/client/src/main/java/jake2/client/CL.java index fee8a7c5..3275a37d 100644 --- a/client/src/main/java/jake2/client/CL.java +++ b/client/src/main/java/jake2/client/CL.java @@ -779,11 +779,7 @@ private static void CL_ConnectionlessPacket(NetworkPacket packet) { */ private static void CL_ReadPackets() { while (true) { - NetworkPacket networkPacket = NET.receiveNetworkPacket( - NET.ip_sockets[NS_CLIENT], - NET.ip_channels[NS_CLIENT], - NET.loopbacks[NS_CLIENT], - false); + NetworkPacket networkPacket = NET.receiveNetworkPacket(NS_CLIENT); if (networkPacket == null) break; diff --git a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt index ed7f77cb..cd32ba41 100644 --- a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -97,7 +97,7 @@ class DedicatedLifetimeTest { Cmd.AddCommand("test_end") { continued = true assertNotNull(Cmd.FindCommand("map")) - assertNull(NET.ip_sockets[jake2.qcommon.Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(jake2.qcommon.Defines.NS_SERVER)) Thread.currentThread().interrupt() } Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_drop", "+test_end")) @@ -108,6 +108,6 @@ class DedicatedLifetimeTest { private fun assertReleased() { assertNull(Cmd.FindCommand("map")) assertNull(Cmd.FindCommand("gamemap")) - assertNull(NET.ip_sockets[jake2.qcommon.Defines.NS_SERVER]) + assertNull(NET.udpEndpoint(jake2.qcommon.Defines.NS_SERVER)) } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 50ea4c85..e99e4cfc 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -29,144 +29,87 @@ import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.network.messages.NetworkPacket; -import java.net.DatagramSocket; +import jake2.qcommon.network.transport.*; + +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.nio.ByteBuffer; -import java.nio.channels.DatagramChannel; -import static jake2.qcommon.Defines.MAX_MSGLEN; import static jake2.qcommon.Defines.NS_SERVER; public final class NET { - private final static int MAX_LOOPBACK = 4; - - /** Local loopback adress. */ + /** Legacy address bridge; transport endpoints themselves use immutable addresses. */ public static final netadr_t net_local_adr = new netadr_t(); - public static class loopmsg_t { - byte[] data = new byte[Defines.MAX_MSGLEN]; - - int datalen; - }; - - public static class loopback_t { - loopmsg_t[] msgs; - int get; - int send; - - - public loopback_t() { - msgs = new loopmsg_t[MAX_LOOPBACK]; - for (int n = 0; n < MAX_LOOPBACK; n++) { - msgs[n] = new loopmsg_t(); - } - } + // Temporary role owners until ClientNetwork and ServerNetwork receive their endpoints. + private static final UdpDatagramEndpoint[] udpEndpoints = new UdpDatagramEndpoint[2]; + private static LoopbackLink loopback = new LoopbackLink(); + public static UdpDatagramEndpoint udpEndpoint(int role) { + return udpEndpoints[role]; } - public static final loopback_t[] loopbacks = new loopback_t[] { - new loopback_t(), new loopback_t() - }; - - public static final DatagramChannel[] ip_channels = { null, null }; - - public static final DatagramSocket[] ip_sockets = { null, null }; - - /* - * ================================================== - * - * LOOPBACK BUFFERS FOR LOCAL PLAYER - * - * ================================================== - */ - - /** - * Sends a packet via internal loopback. - */ - public static void SendLoopPacket(int sock, int length, byte[] data, netadr_t to) { - - loopback_t loop = loopbacks[sock ^ 1]; - - // modulo 4 - int i = loop.send & (MAX_LOOPBACK - 1); - loop.send++; - - System.arraycopy(data, 0, loop.msgs[i].data, 0, length); - loop.msgs[i].datalen = length; + private static LoopbackLink.Endpoint localEndpoint(int role) { + return role == Defines.NS_CLIENT ? loopback.getFirst() : loopback.getSecond(); } - /** - * Gets a packet from internal loopback. - */ - private static NetworkPacket receiveNetworkPacketLoopback(loopback_t loop, boolean fromClient) { - - if (loop.send - loop.get > MAX_LOOPBACK) - loop.get = loop.send - MAX_LOOPBACK; - - if (loop.get >= loop.send) - return null; - - int i = loop.get & (MAX_LOOPBACK - 1); - loop.get++; + public static void SendLoopPacket(int role, int length, byte[] data, netadr_t to) { + localEndpoint(role).send(localEndpoint(role ^ 1).getLocalAddress(), PacketBytes.copyOf(data, length)); + } - return NetworkPacket.fromLoopback(fromClient, loop.msgs[i].data, loop.msgs[i].datalen); + /** Receive only local traffic, including final messages after the UDP role has stopped. */ + public static NetworkPacket receiveLoopbackPacket(int role) { + return decode(localEndpoint(role).receive(), role == NS_SERVER); } - public static NetworkPacket receiveNetworkPacket(DatagramSocket socket, DatagramChannel channel, loopback_t loopback, boolean fromClient) { - NetworkPacket result = receiveNetworkPacketLoopback(loopback, fromClient); + public static NetworkPacket receiveNetworkPacket(int role) { + NetworkPacket local = receiveLoopbackPacket(role); + if (local != null) return local; + UdpDatagramEndpoint endpoint = udpEndpoints[role]; + return endpoint == null ? null : decode(endpoint.receive(), role == NS_SERVER); + } - if (result != null) { - return result; + // Packet decoding stays in the compatibility facade until the PacketCodec extraction. + private static NetworkPacket decode(ReceiveResult result, boolean fromClient) { + if (result instanceof ReceiveResult.Failed failure) { + Com.Println("NET receive ERROR: " + failure.getCause()); } - - if (socket == null) // why? - return null; - + if (!(result instanceof ReceiveResult.Received received)) return null; + Datagram datagram = received.getDatagram(); + byte[] data = datagram.getPayload().toByteArray(); try { - // Receive one extra byte so oversized datagrams can be detected even - // when DatagramChannel truncates them to the buffer capacity. - byte[] bytes = new byte[MAX_MSGLEN + 1]; - ByteBuffer receiveBuffer = ByteBuffer.wrap(bytes); - - InetSocketAddress srcSocket = (InetSocketAddress) channel.receive(receiveBuffer); - if (srcSocket == null) - return null; - - if (receiveBuffer.position() > MAX_MSGLEN) - return null; - - return NetworkPacket.fromSocket(fromClient, bytes, receiveBuffer.position(), srcSocket.getAddress().getAddress(), srcSocket.getPort()); - - } catch (Exception e) { + if (datagram.getSource() instanceof NetworkAddress.Local) { + return NetworkPacket.fromLoopback(fromClient, data, data.length); + } + NetworkAddress.Udp source = (NetworkAddress.Udp) datagram.getSource(); + return NetworkPacket.fromSocket(fromClient, data, data.length, + source.getIp().toByteArray(), source.getPort()); + } catch (RuntimeException malformedPacket) { + // Preserve legacy packet rejection until decoding has explicit outcomes. return null; } } - - /** - * Sends a Packet. - */ - public static void SendPacket(int sock, int length, byte[] data, netadr_t to) { + public static void SendPacket(int role, int length, byte[] data, netadr_t to) { if (to.type == NetAddrType.NA_LOOPBACK) { - SendLoopPacket(sock, length, data, to); + SendLoopPacket(role, length, data, to); return; } - - if (ip_sockets[sock] == null) - return; - + UdpDatagramEndpoint endpoint = udpEndpoints[role]; + if (endpoint == null) return; if (to.type != NetAddrType.NA_BROADCAST && to.type != NetAddrType.NA_IP) { throw new IllegalArgumentException("NET_SendPacket: unsupported address type " + to.type); } - try { - SocketAddress dstSocket = new InetSocketAddress(to.getInetAddress(), to.port); - ip_channels[sock].send(ByteBuffer.wrap(data, 0, length), dstSocket); - } catch (Exception e) { - Com.Println("NET_SendPacket ERROR: " + e + " to " + to.toString()); + NetworkAddress.Udp destination = new NetworkAddress.Udp( + IpAddress.fromInetAddress(to.getInetAddress()), to.port); + SendResult result = endpoint.send(destination, PacketBytes.copyOf(data, length)); + if (result instanceof SendResult.Failed failure) { + Com.Println("NET_SendPacket ERROR: " + failure.getCause() + " to " + to); + } + } catch (IOException | IllegalArgumentException error) { + Com.Println("NET_SendPacket ERROR: " + error + " to " + to); } } @@ -203,40 +146,38 @@ private static boolean configureRole(int role, boolean enabled, String portName, closeRole(role); return true; } - if (ip_sockets[role] != null && !ip_sockets[role].isClosed()) return true; + if (udpEndpoints[role] != null && udpEndpoints[role].isOpen()) return true; closeRole(role); Cvar cvars = Cvar.getInstance(); String ip = cvars.find("ip", String.class).getTypedValue(); int port = cvars.find(portName, Integer.class).getTypedValue(); - ip_sockets[role] = Socket(role, ip, port); - if (ip_sockets[role] == null && fallback) - ip_sockets[role] = Socket(role, ip, Defines.PORT_ANY); - return ip_sockets[role] != null; + udpEndpoints[role] = openEndpoint(ip, port); + if (udpEndpoints[role] == null && fallback) + udpEndpoints[role] = openEndpoint(ip, Defines.PORT_ANY); + return udpEndpoints[role] != null; } private static void closeRole(int role) { - if (ip_sockets[role] != null) ip_sockets[role].close(); - ip_sockets[role] = null; - if (ip_channels[role] != null) { + UdpDatagramEndpoint endpoint = udpEndpoints[role]; + udpEndpoints[role] = null; + if (endpoint != null) { try { - ip_channels[role].close(); - } catch (java.io.IOException error) { - Com.Println("Closing UDP channel: " + error.getMessage()); + endpoint.close(); + } catch (IOException error) { + Com.Println("Closing UDP endpoint: " + error.getMessage()); } - ip_channels[role] = null; } } /** Whole-session boundary only: do not discard reconnect traffic during map changes. */ public static void clearLoopbacks() { - clearLoopback(NS_SERVER); - clearLoopback(Defines.NS_CLIENT); + loopback.close(); + loopback = new LoopbackLink(); } /** Discard incoming packets for one role without dropping the other role's final messages. */ public static void clearLoopback(int role) { - loopbacks[role].get = 0; - loopbacks[role].send = 0; + localEndpoint(role).clearIncoming(); } /** Legacy combined policy for callers that own both socket roles. */ @@ -248,35 +189,19 @@ public static void Config(boolean multiplayer) { } } - /* - * Socket - */ - public static DatagramSocket Socket(int sock, String ip, int port) { - DatagramSocket newsocket = null; + private static UdpDatagramEndpoint openEndpoint(String ip, int port) { try { - if (ip_channels[sock] == null || !ip_channels[sock].isOpen()) - ip_channels[sock] = DatagramChannel.open(); - int bindPort = port == Defines.PORT_ANY ? 0 : port; - newsocket = ip_channels[sock].socket(); - if (ip == null || ip.length() == 0 || ip.equals("localhost")) { - newsocket.bind(new InetSocketAddress(bindPort)); - } else { - InetAddress ia = InetAddress.getByName(ip); - newsocket.bind(new InetSocketAddress(ia, bindPort)); - } - - // nonblocking channel - ip_channels[sock].configureBlocking(false); - // the socket have to be broadcastable - newsocket.setBroadcast(true); - Com.Println("Opened port: " + port); - } catch (Exception e) { - Com.Println("jake2.qcommon.network.NET.Socket: " + e.toString()); - closeRole(sock); // A failed bind may still have opened a channel. - newsocket = null; + InetSocketAddress bindAddress = ip == null || ip.isEmpty() || ip.equals("localhost") + ? new InetSocketAddress(bindPort) + : new InetSocketAddress(InetAddress.getByName(ip), bindPort); + UdpDatagramEndpoint endpoint = UdpDatagramEndpoint.bind(bindAddress); + Com.Println("Opened port: " + endpoint.getLocalAddress().getPort()); + return endpoint; + } catch (IOException error) { + Com.Println("NET bind ERROR: " + error); + return null; } - return newsocket; } /** @@ -289,7 +214,7 @@ public static void Shutdown() { /** Sleeps msec or until net socket is ready. */ public static void Sleep(int msec) { - if (ip_sockets[NS_SERVER] == null + if (udpEndpoints[NS_SERVER] == null || (Globals.dedicated != null && !Globals.dedicated.getTypedValue())) return; // we're not a server, just run full speed @@ -298,29 +223,5 @@ public static void Sleep(int msec) { Thread.sleep(msec); } catch (InterruptedException e) { } - //ip_sockets[NS_SERVER]. - - // this should wait up to 100ms until a packet - /* - * struct timeval timeout; - * fd_set fdset; - * extern cvar_t *dedicated; - * extern qboolean stdin_active; - * - * if (!ip_sockets[NS_SERVER] || (dedicated && !dedicated.value)) - * return; // we're not a server, just run full speed - * - * FD_ZERO(&fdset); - * - * if (stdin_active) - * FD_SET(0, &fdset); // stdin is processed too - * - * FD_SET(ip_sockets[NS_SERVER], &fdset); // network socket - * - * timeout.tv_sec = msec/1000; - * timeout.tv_usec = (msec%1000)*1000; - * - * select(ip_sockets[NS_SERVER]+1, &fdset, NULL, NULL, &timeout); - */ } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/DatagramEndpoint.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/DatagramEndpoint.kt new file mode 100644 index 00000000..4c446aa3 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/DatagramEndpoint.kt @@ -0,0 +1,35 @@ +package jake2.qcommon.network.transport + +import java.io.IOException + +/** Owned transport resource. Operations are confined to the owner's game-loop thread. */ +interface DatagramEndpoint : AutoCloseable { + val localAddress: NetworkAddress + val isOpen: Boolean + + /** Nonblocking. An empty poll is distinct from a discarded datagram or I/O failure. */ + fun receive(): ReceiveResult + + /** Sent means accepted by the transport, not acknowledged by the peer. */ + fun send(to: NetworkAddress, payload: PacketBytes): SendResult + + @Throws(IOException::class) + override fun close() +} + +data class Datagram(val source: NetworkAddress, val payload: PacketBytes) + +sealed interface ReceiveResult { + data class Received(val datagram: Datagram) : ReceiveResult + data object Empty : ReceiveResult + data class Dropped(val reason: DatagramDropReason) : ReceiveResult + data class Failed(val cause: IOException) : ReceiveResult +} + +enum class DatagramDropReason { OVERSIZED } + +sealed interface SendResult { + data object Sent : SendResult + data object WouldBlock : SendResult + data class Failed(val cause: IOException) : SendResult +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/LoopbackLink.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/LoopbackLink.kt new file mode 100644 index 00000000..eed02c54 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/LoopbackLink.kt @@ -0,0 +1,57 @@ +package jake2.qcommon.network.transport + +import jake2.qcommon.Defines +import java.nio.channels.ClosedChannelException +import java.util.UUID + +/** A private pair of bounded queues, used on one owner thread just like UDP endpoints. */ +class LoopbackLink @JvmOverloads constructor( + private val capacity: Int = 4, + private val maxPacketSize: Int = Defines.MAX_MSGLEN, +) : AutoCloseable { + init { + require(capacity > 0) { "Loopback queue capacity must be positive" } + require(maxPacketSize > 0) { "Maximum datagram size must be positive" } + } + + private val id = UUID.randomUUID() + val first = Endpoint(LocalSide.FIRST) + val second = Endpoint(LocalSide.SECOND) + + override fun close() { + first.close() + second.close() + } + + inner class Endpoint internal constructor(side: LocalSide) : DatagramEndpoint { + override val localAddress = NetworkAddress.Local(id, side) + override var isOpen: Boolean = true + private set + private val incoming = ArrayDeque() + private val peer: Endpoint get() = if (this === first) second else first + + override fun receive(): ReceiveResult = when { + !isOpen -> ReceiveResult.Failed(ClosedChannelException()) + incoming.isEmpty() -> ReceiveResult.Empty + else -> ReceiveResult.Received(incoming.removeFirst()) + } + + override fun send(to: NetworkAddress, payload: PacketBytes): SendResult { + require(to == peer.localAddress) { "Destination belongs to a different loopback endpoint" } + require(payload.size <= maxPacketSize) { "Datagram exceeds $maxPacketSize bytes" } + if (!isOpen || !peer.isOpen) return SendResult.Failed(ClosedChannelException()) + // Preserve the legacy bounded-queue policy: retain the most recent datagrams. + if (peer.incoming.size == capacity) peer.incoming.removeFirst() + peer.incoming.addLast(Datagram(localAddress, payload)) + return SendResult.Sent + } + + /** Retire only this receiver's queued traffic, preserving the peer's final messages. */ + fun clearIncoming() = incoming.clear() + + override fun close() { + isOpen = false + clearIncoming() + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt new file mode 100644 index 00000000..88f3d679 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt @@ -0,0 +1,57 @@ +package jake2.qcommon.network.transport + +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.UUID + +/** Resolved IP value. Copies bytes at the boundary and retains IPv6 scope identity. */ +class IpAddress private constructor(private val bytes: ByteArray, private val scopeId: Int) { + fun toByteArray(): ByteArray = bytes.copyOf() + + fun toInetAddress(): InetAddress = if (bytes.size == 16) { + Inet6Address.getByAddress(null, bytes, scopeId) + } else { + InetAddress.getByAddress(bytes) + } + + override fun equals(other: Any?): Boolean = + other is IpAddress && scopeId == other.scopeId && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + scopeId + override fun toString(): String = toInetAddress().hostAddress + + companion object { + @JvmStatic + fun fromBytes(bytes: ByteArray): IpAddress { + require(bytes.size == 4 || bytes.size == 16) { "Expected an IPv4 or IPv6 address" } + return IpAddress(bytes.copyOf(), 0) + } + + @JvmStatic + fun fromInetAddress(address: InetAddress): IpAddress = + IpAddress(address.address, (address as? Inet6Address)?.scopeId ?: 0) + } +} + +sealed interface NetworkAddress { + data class Udp(val ip: IpAddress, val port: Int) : NetworkAddress { + init { + require(port in 0..65535) { "UDP port out of range: $port" } + } + + fun toSocketAddress(): InetSocketAddress = InetSocketAddress(ip.toInetAddress(), port) + + companion object { + @JvmStatic + fun fromSocketAddress(address: InetSocketAddress): Udp { + require(!address.isUnresolved) { "Resolve the hostname before opening an endpoint" } + return Udp(IpAddress.fromInetAddress(address.address), address.port) + } + } + } + + data class Local(val linkId: UUID, val side: LocalSide) : NetworkAddress +} + +enum class LocalSide { FIRST, SECOND } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt new file mode 100644 index 00000000..c7f62fcb --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt @@ -0,0 +1,23 @@ +package jake2.qcommon.network.transport + +import java.nio.ByteBuffer + +/** An owned payload, safe to retain in a queue after the sender reuses its buffer. */ +class PacketBytes private constructor(private val bytes: ByteArray) { + val size: Int get() = bytes.size + + fun toByteArray(): ByteArray = bytes.copyOf() + internal fun readOnlyBuffer(): ByteBuffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer() + + override fun equals(other: Any?): Boolean = other is PacketBytes && bytes.contentEquals(other.bytes) + override fun hashCode(): Int = bytes.contentHashCode() + + companion object { + @JvmStatic + @JvmOverloads + fun copyOf(bytes: ByteArray, length: Int = bytes.size): PacketBytes { + require(length in 0..bytes.size) { "Payload length exceeds its backing array" } + return PacketBytes(bytes.copyOf(length)) + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt new file mode 100644 index 00000000..0bcf7bd8 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt @@ -0,0 +1,71 @@ +package jake2.qcommon.network.transport + +import jake2.qcommon.Defines +import java.io.IOException +import java.net.InetSocketAddress +import java.net.StandardSocketOptions +import java.nio.ByteBuffer +import java.nio.channels.DatagramChannel + +/** Owns one nonblocking channel. No socket or channel handle escapes this class. */ +class UdpDatagramEndpoint private constructor( + private val channel: DatagramChannel, + private val maxPacketSize: Int, +) : DatagramEndpoint { + override val localAddress = NetworkAddress.Udp.fromSocketAddress(channel.localAddress as InetSocketAddress) + override val isOpen: Boolean get() = channel.isOpen + + override fun receive(): ReceiveResult = try { + // The extra byte distinguishes a full-sized packet from a truncated oversized one. + val buffer = ByteBuffer.allocate(maxPacketSize + 1) + val source = channel.receive(buffer) as InetSocketAddress? + when { + source == null -> ReceiveResult.Empty + buffer.position() > maxPacketSize -> ReceiveResult.Dropped(DatagramDropReason.OVERSIZED) + else -> ReceiveResult.Received(Datagram( + NetworkAddress.Udp.fromSocketAddress(source), + PacketBytes.copyOf(buffer.array(), buffer.position()), + )) + } + } catch (failure: IOException) { + ReceiveResult.Failed(failure) + } + + override fun send(to: NetworkAddress, payload: PacketBytes): SendResult { + require(to is NetworkAddress.Udp) { "UDP endpoint requires a UDP destination" } + require(payload.size <= maxPacketSize) { "Datagram exceeds $maxPacketSize bytes" } + return try { + val sent = channel.send(payload.readOnlyBuffer(), to.toSocketAddress()) + if (sent == payload.size) SendResult.Sent else SendResult.WouldBlock + } catch (failure: IOException) { + SendResult.Failed(failure) + } + } + + @Throws(IOException::class) + override fun close() = channel.close() + + companion object { + @JvmStatic + @JvmOverloads + @Throws(IOException::class) + fun bind(address: InetSocketAddress, maxPacketSize: Int = Defines.MAX_MSGLEN): UdpDatagramEndpoint { + require(!address.isUnresolved) { "Resolve the hostname before opening an endpoint" } + require(maxPacketSize in 1..65507) { "Invalid maximum datagram size" } + val channel = DatagramChannel.open() + try { + channel.bind(address) + channel.configureBlocking(false) + channel.setOption(StandardSocketOptions.SO_BROADCAST, true) + return UdpDatagramEndpoint(channel, maxPacketSize) + } catch (failure: Exception) { + try { + channel.close() + } catch (closeFailure: IOException) { + failure.addSuppressed(closeFailure) + } + throw failure + } + } + } +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index 4a6c330f..585bffb1 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -7,28 +7,30 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.ValueSource; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.channels.DatagramChannel; -import java.nio.channels.SelectionKey; -import java.nio.channels.Selector; import java.util.Arrays; +import java.util.concurrent.locks.LockSupport; -import static jake2.qcommon.Defines.MAX_MSGLEN; +import static jake2.qcommon.Defines.*; import static org.junit.jupiter.api.Assertions.*; class NetPacketReceiveTest { @BeforeEach void setup() { + NET.Config(false); + NET.clearLoopbacks(); Cvar.getInstance().clear(); Netchan.Netchan_Init(); } @AfterEach void cleanup() { + NET.Config(false); + NET.clearLoopbacks(); Cvar.getInstance().clear(); } @@ -47,12 +49,6 @@ void receivesWholeDatagramAtSizeBoundary(int length, boolean fromClient) throws assertEquals(NetAddrType.NA_IP, packet.from.type); } - @ParameterizedTest - @ValueSource(ints = {1401, 4096}) - void rejectsOversizedDatagramsInsteadOfAcceptingTruncatedPayload(int length) throws Exception { - assertNull(receive(sequencedPacket(length, false), false)); - } - @Test void socketPacketAcceptsAnExactlySizedArray() { byte[] bytes = sequencedPacket(MAX_MSGLEN, true); @@ -89,20 +85,20 @@ private static byte[] sequencedPacket(int length, boolean fromClient) { } private static NetworkPacket receive(byte[] bytes, boolean fromClient) throws Exception { - try (var receiver = DatagramChannel.open(); - var sender = DatagramChannel.open(); - var selector = Selector.open()) { - receiver.bind(new InetSocketAddress("127.0.0.1", 0)); - receiver.configureBlocking(false); - receiver.register(selector, SelectionKey.OP_READ); - assertEquals(bytes.length, sender.send(ByteBuffer.wrap(bytes), receiver.getLocalAddress())); - + int role = fromClient ? NS_SERVER : NS_CLIENT; + Cvar.getInstance().find(fromClient ? "port" : "clientport", Integer.class).forceSet(0); + assertTrue(fromClient ? NET.configureServer(true) : NET.configureClient(true)); + try (var sender = DatagramChannel.open()) { + var destination = new InetSocketAddress("127.0.0.1", NET.udpEndpoint(role).getLocalAddress().getPort()); + assertEquals(bytes.length, sender.send(ByteBuffer.wrap(bytes), destination)); long deadline = System.nanoTime() + 2_000_000_000L; - while (selector.selectedKeys().isEmpty() && System.nanoTime() < deadline) { - selector.select(100); + while (System.nanoTime() < deadline) { + NetworkPacket packet = NET.receiveNetworkPacket(role); + if (packet != null) return packet; + LockSupport.parkNanos(1_000_000); } - assertFalse(selector.selectedKeys().isEmpty(), "UDP datagram did not arrive"); - return NET.receiveNetworkPacket(receiver.socket(), receiver, new NET.loopback_t(), fromClient); + fail("UDP datagram did not arrive"); + return null; } } } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index a3d0ecea..95c4a065 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -38,19 +38,19 @@ void cleanup() { @Test void clientTeardownPreservesServerAndServerTeardownPreservesClient() { assertTrue(NET.configureServer(true)); - var server = NET.ip_sockets[NS_SERVER]; - assertNull(NET.ip_sockets[NS_CLIENT]); + var server = NET.udpEndpoint(NS_SERVER); + assertNull(NET.udpEndpoint(NS_CLIENT)); assertTrue(NET.configureClient(true)); NET.configureClient(false); - assertSame(server, NET.ip_sockets[NS_SERVER]); - assertFalse(server.isClosed()); - assertNull(NET.ip_channels[NS_CLIENT]); + assertSame(server, NET.udpEndpoint(NS_SERVER)); + assertTrue(server.isOpen()); + assertNull(NET.udpEndpoint(NS_CLIENT)); assertTrue(NET.configureClient(true)); - var client = NET.ip_sockets[NS_CLIENT]; + var client = NET.udpEndpoint(NS_CLIENT); NET.configureServer(false); - assertSame(client, NET.ip_sockets[NS_CLIENT]); - assertFalse(client.isClosed()); - assertNull(NET.ip_channels[NS_SERVER]); + assertSame(client, NET.udpEndpoint(NS_CLIENT)); + assertTrue(client.isOpen()); + assertNull(NET.udpEndpoint(NS_SERVER)); } @Test @@ -58,8 +58,7 @@ void failedServerBindReleasesChannelAndAllowsRetry() throws Exception { try (var occupied = new DatagramSocket(0)) { Cvar.getInstance().find("port", Integer.class).forceSet(occupied.getLocalPort()); assertFalse(NET.configureServer(true)); - assertNull(NET.ip_sockets[NS_SERVER]); - assertNull(NET.ip_channels[NS_SERVER]); + assertNull(NET.udpEndpoint(NS_SERVER)); } assertTrue(NET.configureServer(true)); } @@ -71,8 +70,8 @@ void explicitBindAddressSupportsEphemeralPorts(int port) throws Exception { Cvar.getInstance().find("port", Integer.class).forceSet(port); assertTrue(NET.configureServer(true)); - assertEquals(InetAddress.getByName("127.0.0.1"), NET.ip_sockets[NS_SERVER].getLocalAddress()); - assertTrue(NET.ip_sockets[NS_SERVER].getLocalPort() > 0); + assertEquals(InetAddress.getByName("127.0.0.1"), NET.udpEndpoint(NS_SERVER).getLocalAddress().getIp().toInetAddress()); + assertTrue(NET.udpEndpoint(NS_SERVER).getLocalAddress().getPort() > 0); } @Test @@ -82,23 +81,22 @@ void occupiedClientPortFallsBackOnExplicitBindAddress() throws Exception { Cvar.getInstance().find("clientport", Integer.class).forceSet(occupied.getLocalPort()); assertTrue(NET.configureClient(true)); - var client = NET.ip_sockets[NS_CLIENT]; - assertEquals(occupied.getLocalAddress(), client.getLocalAddress()); - assertTrue(client.getLocalPort() > 0); - assertNotEquals(occupied.getLocalPort(), client.getLocalPort()); - assertTrue(NET.ip_channels[NS_CLIENT].isOpen()); - assertFalse(NET.ip_channels[NS_CLIENT].isBlocking()); + var client = NET.udpEndpoint(NS_CLIENT); + assertEquals(occupied.getLocalAddress(), client.getLocalAddress().getIp().toInetAddress()); + assertTrue(client.getLocalAddress().getPort() > 0); + assertNotEquals(occupied.getLocalPort(), client.getLocalAddress().getPort()); + assertTrue(NET.udpEndpoint(NS_CLIENT).isOpen()); } } @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); - assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[NS_SERVER], true)); + assertNotNull(NET.receiveLoopbackPacket(NS_SERVER)); Netchan.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.client_connect, ""); Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); NET.clearLoopbacks(); - assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[NS_CLIENT], false)); - assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[NS_SERVER], true)); + assertNull(NET.receiveLoopbackPacket(NS_CLIENT)); + assertNull(NET.receiveLoopbackPacket(NS_SERVER)); } } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt index 29b52246..10ec77c9 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -7,7 +7,8 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource -import java.net.DatagramSocket +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.Netchan class OutputFailureTest { @Test @@ -44,19 +45,23 @@ class OutputFailureTest { @ParameterizedTest @EnumSource(NetAddrType::class, names = ["NA_IPX", "NA_BROADCAST_IPX"]) fun `unsupported destination propagates without closing the socket`(type: NetAddrType) { - val previous = NET.ip_sockets[Defines.NS_SERVER] - DatagramSocket(0).use { socket -> - NET.ip_sockets[Defines.NS_SERVER] = socket - try { - val failure = assertThrows(IllegalArgumentException::class.java) { - NET.SendPacket(Defines.NS_SERVER, 1, byteArrayOf(1), netadr_t().apply { this.type = type }) - } - assertTrue(failure.message!!.contains(type.name)) - assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) - assertFalse(socket.isClosed) - } finally { - NET.ip_sockets[Defines.NS_SERVER] = previous + NET.Config(false) + val cvars = Cvar.getInstance() + cvars.clear() + Netchan.Netchan_Init() + cvars.find("port", Int::class.javaObjectType).forceSet(0) + try { + assertTrue(NET.configureServer(true)) + val endpoint = NET.udpEndpoint(Defines.NS_SERVER) + val failure = assertThrows(IllegalArgumentException::class.java) { + NET.SendPacket(Defines.NS_SERVER, 1, byteArrayOf(1), netadr_t().apply { this.type = type }) } + assertTrue(failure.message!!.contains(type.name)) + assertSame(endpoint, NET.udpEndpoint(Defines.NS_SERVER)) + assertTrue(endpoint.isOpen) + } finally { + NET.configureServer(false) + cvars.clear() } } } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/transport/DatagramEndpointTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/transport/DatagramEndpointTest.kt new file mode 100644 index 00000000..e18195a2 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/transport/DatagramEndpointTest.kt @@ -0,0 +1,197 @@ +package jake2.qcommon.network.transport + +import jake2.qcommon.Defines.MAX_MSGLEN +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.IOException +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.time.Duration +import java.util.concurrent.locks.LockSupport + +class DatagramEndpointTest { + @Test + fun `address and payload values own their bytes and have content equality`() { + val bytes = byteArrayOf(127, 0, 0, 1) + val ip = IpAddress.fromBytes(bytes) + val payload = PacketBytes.copyOf(bytes) + bytes[0] = 42 + ip.toByteArray()[0] = 11 + payload.toByteArray()[0] = 12 + + val sameIp = IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)) + assertEquals(ip, sameIp) + assertEquals(ip.hashCode(), sameIp.hashCode()) + assertEquals(payload, PacketBytes.copyOf(byteArrayOf(127, 0, 0, 1))) + assertEquals("value", mapOf(NetworkAddress.Udp(ip, 27910) to "value")[NetworkAddress.Udp(sameIp, 27910)]) + assertNotEquals(NetworkAddress.Udp(ip, 27910), NetworkAddress.Udp(ip, 27911)) + } + + @Test + fun `IPv6 scope participates in address identity`() { + val bytes = InetAddress.getByName("fe80::1").address + val first = IpAddress.fromInetAddress(Inet6Address.getByAddress(null, bytes, 1)) + val second = IpAddress.fromInetAddress(Inet6Address.getByAddress(null, bytes, 2)) + assertNotEquals(first, second) + assertEquals(1, (first.toInetAddress() as Inet6Address).scopeId) + } + + @Test + fun `two loopback pairs exchange bytes independently`() { + LoopbackLink().use { a -> + LoopbackLink().use { b -> + val first = PacketBytes.copyOf(byteArrayOf(1)) + val second = PacketBytes.copyOf(byteArrayOf(2)) + assertEquals(SendResult.Sent, a.first.send(a.second.localAddress, first)) + assertEquals(SendResult.Sent, b.first.send(b.second.localAddress, second)) + assertEquals(Datagram(a.first.localAddress, first), received(a.second).datagram) + assertEquals(Datagram(b.first.localAddress, second), received(b.second).datagram) + assertEquals(ReceiveResult.Empty, a.first.receive()) + assertEquals(ReceiveResult.Empty, b.first.receive()) + assertThrows(IllegalArgumentException::class.java) { a.first.send(b.second.localAddress, first) } + a.close() + assertTrue(b.first.isOpen) + assertEquals(SendResult.Sent, b.second.send(b.first.localAddress, second)) + assertEquals(second, received(b.first).datagram.payload) + } + } + } + + @Test + fun `loopback overflow keeps newest packets and receive buffers stay owned`() { + LoopbackLink(capacity = 2).use { link -> + val buffer = byteArrayOf(1) + link.first.send(link.second.localAddress, PacketBytes.copyOf(buffer)) + val retained = received(link.second).datagram + for (value in 2..4) { + buffer[0] = value.toByte() + link.first.send(link.second.localAddress, PacketBytes.copyOf(buffer)) + } + assertArrayEquals(byteArrayOf(1), retained.payload.toByteArray()) + assertArrayEquals(byteArrayOf(3), received(link.second).datagram.payload.toByteArray()) + assertArrayEquals(byteArrayOf(4), received(link.second).datagram.payload.toByteArray()) + assertEquals(ReceiveResult.Empty, link.second.receive()) + } + } + + @Test + fun `closing one local endpoint preserves final messages at its peer`() { + LoopbackLink().use { link -> + val finalMessage = PacketBytes.copyOf(byteArrayOf(9)) + link.first.send(link.second.localAddress, finalMessage) + link.second.send(link.first.localAddress, finalMessage) + link.first.close() + link.first.close() + assertFalse(link.first.isOpen) + assertTrue(link.second.isOpen) + assertEquals(finalMessage, received(link.second).datagram.payload) + assertEquals(ReceiveResult.Empty, link.second.receive()) + assertInstanceOf(ReceiveResult.Failed::class.java, link.first.receive()) + assertInstanceOf(SendResult.Failed::class.java, link.second.send(link.first.localAddress, finalMessage)) + } + } + + @Test + fun `clearing a local inbox preserves traffic in the other direction`() { + LoopbackLink().use { link -> + val payload = PacketBytes.copyOf(byteArrayOf(1)) + link.first.send(link.second.localAddress, payload) + link.second.send(link.first.localAddress, payload) + link.first.clearIncoming() + assertEquals(ReceiveResult.Empty, link.first.receive()) + assertEquals(payload, received(link.second).datagram.payload) + } + } + + @Test + fun `UDP endpoints are nonblocking and independent with full size datagrams`() { + udp().use { a -> + udp().use { b -> + udp().use { c -> + udp().use { d -> + assertTimeoutPreemptively(Duration.ofSeconds(2)) { + assertEquals(ReceiveResult.Empty, a.receive()) + } + val full = PacketBytes.copyOf(ByteArray(MAX_MSGLEN) { it.toByte() }) + val small = PacketBytes.copyOf(byteArrayOf(7)) + assertEquals(SendResult.Sent, a.send(b.localAddress, full)) + assertEquals(SendResult.Sent, c.send(d.localAddress, small)) + assertEquals(Datagram(a.localAddress, full), received(b).datagram) + assertEquals(Datagram(c.localAddress, small), received(d).datagram) + a.close() + assertTrue(b.isOpen) + assertTrue(c.isOpen) + assertEquals(SendResult.Sent, d.send(c.localAddress, small)) + assertEquals(small, received(c).datagram.payload) + assertInstanceOf(ReceiveResult.Failed::class.java, a.receive()) + assertInstanceOf(SendResult.Failed::class.java, a.send(b.localAddress, small)) + } + } + } + } + } + + @ParameterizedTest + @ValueSource(ints = [1401, 4096]) + fun `oversized UDP datagrams are reported and do not block later packets`(size: Int) { + udp().use { receiver -> + DatagramSocket().use { sender -> + val bytes = ByteArray(size) + sender.send(DatagramPacket(bytes, bytes.size, receiver.localAddress.toSocketAddress())) + assertEquals(ReceiveResult.Dropped(DatagramDropReason.OVERSIZED), awaitPacket(receiver)) + val valid = byteArrayOf(1, 2) + sender.send(DatagramPacket(valid, valid.size, receiver.localAddress.toSocketAddress())) + assertArrayEquals(valid, received(receiver).datagram.payload.toByteArray()) + } + } + } + + @Test + fun `UDP bind failure leaves the occupied endpoint intact and allows retry`() { + val address = udp().use { occupied -> + val address = occupied.localAddress.toSocketAddress() + assertThrows(IOException::class.java) { UdpDatagramEndpoint.bind(address) } + assertTrue(occupied.isOpen) + address + } + UdpDatagramEndpoint.bind(address).use { retry -> + assertTrue(retry.isOpen) + assertEquals(address.port, retry.localAddress.port) + } + } + + @Test + fun `transports reject oversized outgoing payloads before changing state`() { + val oversized = PacketBytes.copyOf(ByteArray(MAX_MSGLEN + 1)) + LoopbackLink().use { link -> + assertThrows(IllegalArgumentException::class.java) { link.first.send(link.second.localAddress, oversized) } + assertEquals(ReceiveResult.Empty, link.second.receive()) + } + udp().use { endpoint -> + assertThrows(IllegalArgumentException::class.java) { endpoint.send(endpoint.localAddress, oversized) } + assertEquals(ReceiveResult.Empty, endpoint.receive()) + assertTrue(endpoint.isOpen) + } + } + + private fun udp() = UdpDatagramEndpoint.bind(InetSocketAddress("127.0.0.1", 0)) + + private fun received(endpoint: DatagramEndpoint): ReceiveResult.Received = + assertInstanceOf(ReceiveResult.Received::class.java, awaitPacket(endpoint)) + + private fun awaitPacket(endpoint: DatagramEndpoint): ReceiveResult { + val deadline = System.nanoTime() + 2_000_000_000L + do { + val result = endpoint.receive() + if (result != ReceiveResult.Empty) return result + LockSupport.parkNanos(1_000_000) + } while (System.nanoTime() < deadline) + fail("Datagram did not arrive") + return ReceiveResult.Empty + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 4ff88ac0..5a2d129a 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1246,12 +1246,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) */ fun readPackets() { while (true) { - val networkPacket = NET.receiveNetworkPacket( - NET.ip_sockets[Defines.NS_SERVER], - NET.ip_channels[Defines.NS_SERVER], - NET.loopbacks[Defines.NS_SERVER], - true - ) + val networkPacket = NET.receiveNetworkPacket(Defines.NS_SERVER) if (networkPacket == null) break From 93bb25bd9fbe69511f0da5261cf4bc8834d8dee9 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 22:56:35 +0100 Subject: [PATCH 19/57] Extract stateless packet envelope decoder with explicit rejection outcomes --- .../main/java/jake2/qcommon/network/NET.java | 33 ++-- .../network/messages/NetworkPacket.java | 78 +++------ .../qcommon/network/protocol/PacketCodec.kt | 47 ++++++ .../network/protocol/PacketEnvelope.kt | 44 +++++ .../qcommon/network/NetPacketReceiveTest.java | 39 ++++- .../jake2/qcommon/network/NetchanTest.java | 11 +- .../network/protocol/PacketCodecTest.kt | 157 ++++++++++++++++++ 7 files changed, 339 insertions(+), 70 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketEnvelope.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index e99e4cfc..b2844d09 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -28,7 +28,9 @@ import jake2.qcommon.exec.Cvar; import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.network.messages.NetworkPacket; - +import jake2.qcommon.network.protocol.PacketCodec; +import jake2.qcommon.network.protocol.PacketDecodeResult; +import jake2.qcommon.network.protocol.PacketDirection; import jake2.qcommon.network.transport.*; import java.io.IOException; @@ -60,35 +62,38 @@ public static void SendLoopPacket(int role, int length, byte[] data, netadr_t to /** Receive only local traffic, including final messages after the UDP role has stopped. */ public static NetworkPacket receiveLoopbackPacket(int role) { - return decode(localEndpoint(role).receive(), role == NS_SERVER); + return adaptReceived(localEndpoint(role).receive(), role == NS_SERVER); } public static NetworkPacket receiveNetworkPacket(int role) { NetworkPacket local = receiveLoopbackPacket(role); if (local != null) return local; UdpDatagramEndpoint endpoint = udpEndpoints[role]; - return endpoint == null ? null : decode(endpoint.receive(), role == NS_SERVER); + return endpoint == null ? null : adaptReceived(endpoint.receive(), role == NS_SERVER); } - // Packet decoding stays in the compatibility facade until the PacketCodec extraction. - private static NetworkPacket decode(ReceiveResult result, boolean fromClient) { + // Legacy nullable API and trace output; decoding itself has explicit, side-effect-free outcomes. + private static NetworkPacket adaptReceived(ReceiveResult result, boolean fromClient) { if (result instanceof ReceiveResult.Failed failure) { Com.Println("NET receive ERROR: " + failure.getCause()); } if (!(result instanceof ReceiveResult.Received received)) return null; Datagram datagram = received.getDatagram(); - byte[] data = datagram.getPayload().toByteArray(); - try { - if (datagram.getSource() instanceof NetworkAddress.Local) { - return NetworkPacket.fromLoopback(fromClient, data, data.length); + PacketDecodeResult decoded = PacketCodec.decode(datagram.getPayload(), + fromClient ? PacketDirection.FROM_CLIENT : PacketDirection.FROM_SERVER); + if (decoded instanceof PacketDecodeResult.Rejected rejected) { + if (Netchan.showdrop != null && Netchan.showdrop.getTypedValue()) { + Com.Println("NET dropped packet from " + datagram.getSource() + ": " + rejected.getReason()); } - NetworkAddress.Udp source = (NetworkAddress.Udp) datagram.getSource(); - return NetworkPacket.fromSocket(fromClient, data, data.length, - source.getIp().toByteArray(), source.getPort()); - } catch (RuntimeException malformedPacket) { - // Preserve legacy packet rejection until decoding has explicit outcomes. return null; } + NetworkPacket packet = NetworkPacket.fromEnvelope(datagram.getSource(), + ((PacketDecodeResult.Decoded) decoded).getEnvelope()); + if (packet.isConnectionless() && Netchan.printNetwork != null && Netchan.printNetwork.getTypedValue()) { + Com.Printf("Network: Received from " + (fromClient ? "client" : "server") + + " connectionless: " + packet.connectionlessMessage + "\n"); + } + return packet; } public static void SendPacket(int role, int length, byte[] data, netadr_t to) { diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index f0bbc7fc..cae29e4d 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -4,6 +4,8 @@ import jake2.qcommon.Defines; import jake2.qcommon.network.Netchan; import jake2.qcommon.network.NetAddrType; +import jake2.qcommon.network.protocol.PacketEnvelope; +import jake2.qcommon.network.transport.NetworkAddress; import jake2.qcommon.network.messages.client.ClientMessage; import jake2.qcommon.network.messages.client.EndOfClientPacketMessage; import jake2.qcommon.network.messages.client.MoveMessage; @@ -17,9 +19,6 @@ public class NetworkPacket { - // should parse port - private final boolean fromClient; - // Long, -1 for connectionless public int sequence; @@ -39,63 +38,38 @@ public class NetworkPacket { public sizebuf_t buffer = new sizebuf_t(); - public static NetworkPacket fromLoopback(boolean fromClient, byte[] data, int datalen) { - // localhost by default - NetworkPacket loopbackPacket = new NetworkPacket(fromClient); - loopbackPacket.buffer.data = new byte[datalen]; - loopbackPacket.buffer.cursize = datalen; - System.arraycopy(data, 0, loopbackPacket.buffer.data, 0, datalen); - loopbackPacket.parseHeader(); - return loopbackPacket; - } - - public static NetworkPacket fromSocket(boolean fromClient, byte[] data, int datalen, byte[] address, int port) { - NetworkPacket networkPacket = new NetworkPacket(fromClient); - networkPacket.from.ip = address; - networkPacket.from.port = port; - networkPacket.from.type = NetAddrType.NA_IP; - networkPacket.buffer.cursize = datalen; - networkPacket.buffer.data = data; - // Reads are bounded by cursize; the backing array needs no sentinel byte. - networkPacket.parseHeader(); - return networkPacket; + /** Adapt a validated envelope to the mutable legacy channel/message reader. */ + public static NetworkPacket fromEnvelope(NetworkAddress source, PacketEnvelope envelope) { + NetworkPacket packet = new NetworkPacket(); + packet.buffer.data = envelope.getBytes().toByteArray(); + packet.buffer.cursize = packet.buffer.data.length; + packet.buffer.maxsize = packet.buffer.data.length; + if (source instanceof NetworkAddress.Udp udp) { + packet.from.type = NetAddrType.NA_IP; + packet.from.ip = udp.getIp().toByteArray(); + packet.from.port = udp.getPort(); + } + if (envelope instanceof PacketEnvelope.Sequenced sequenced) { + packet.sequence = sequenced.getSequence(); + packet.sequenceAck = sequenced.getSequenceAck(); + packet.qport = sequenced.getQport() == null ? 0 : sequenced.getQport(); + packet.buffer.readcount = sequenced.getHeaderSize(); + } else if (envelope instanceof PacketEnvelope.Connectionless connectionless) { + packet.sequence = -1; + packet.connectionlessMessage = connectionless.getCommandLine(); + packet.connectionlessParameters = connectionless.getParameters(); + packet.buffer.readcount = packet.buffer.cursize; + } + return packet; } - private NetworkPacket(boolean fromClient) { - this.fromClient = fromClient; + private NetworkPacket() { } public boolean isConnectionless() { return sequence == -1; } - public void parseHeader() { - sequence = buffer.readInt(); - - if (isConnectionless()) { - connectionlessMessage = buffer.readString(); - if (Netchan.printNetwork.getTypedValue()) { - Com.Printf("Network: Received from " + (fromClient ? "client" : "server") + " connectionless: " + connectionlessMessage + "\n"); - } - if (!fromClient) { - switch (connectionlessMessage) { - case "info": - case "print": - connectionlessParameters = buffer.readString(); - break; - default: - break; - } - - } - } else { - sequenceAck = buffer.readInt(); - - if (fromClient) - qport = buffer.readShort() & 0xffff; - } - } - public Collection parseBodyFromServer() { List result = new ArrayList<>(); while (true) { diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt new file mode 100644 index 00000000..4fd93c07 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt @@ -0,0 +1,47 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines +import jake2.qcommon.network.transport.PacketBytes +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Stateless envelope decoding. No sockets, Cvars, logging, or mutable channel state. */ +object PacketCodec { + @JvmStatic + fun decode(bytes: PacketBytes, direction: PacketDirection): PacketDecodeResult { + if (bytes.size > Defines.MAX_MSGLEN) return PacketDecodeResult.Rejected(PacketRejection.OVERSIZED) + if (bytes.size < 4) return PacketDecodeResult.Rejected(PacketRejection.TRUNCATED_HEADER) + + val reader = bytes.readOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN) + val sequence = reader.int + if (sequence == -1) { + val commandLine = readText(reader, stopAtNewline = true) + if (commandLine.isEmpty()) return PacketDecodeResult.Rejected(PacketRejection.EMPTY_CONNECTIONLESS_COMMAND) + val parameters = if (direction == PacketDirection.FROM_SERVER && + (commandLine == "info" || commandLine == "print")) { + readText(reader, stopAtNewline = false) + } else null + return PacketDecodeResult.Decoded(PacketEnvelope.Connectionless(bytes, commandLine, parameters)) + } + + if (bytes.size < direction.sequencedHeaderSize) { + return PacketDecodeResult.Rejected(PacketRejection.TRUNCATED_HEADER) + } + val sequenceAck = reader.int + val qport = if (direction == PacketDirection.FROM_CLIENT) reader.short.toInt() and 0xffff else null + return PacketDecodeResult.Decoded(PacketEnvelope.Sequenced(bytes, sequence, sequenceAck, qport)) + } + + private fun readText(reader: ByteBuffer, stopAtNewline: Boolean): String { + val text = ByteArray(reader.remaining()) + var length = 0 + while (reader.hasRemaining()) { + val value = reader.get().toInt() and 0xff + // Retain the legacy NUL/0xff terminators and accept the datagram boundary. + if (value == 0 || value == 0xff || (stopAtNewline && value == '\n'.code)) break + text[length++] = value.toByte() + } + // Matches Lib.stringToBytes used by outgoing connectionless messages. + return String(text, 0, length, Charsets.ISO_8859_1) + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketEnvelope.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketEnvelope.kt new file mode 100644 index 00000000..968215cc --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketEnvelope.kt @@ -0,0 +1,44 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.network.transport.PacketBytes + +enum class PacketDirection(val sequencedHeaderSize: Int) { + FROM_CLIENT(10), + FROM_SERVER(8), +} + +/** Validated envelope retaining the original bytes for the existing message-body reader. */ +sealed interface PacketEnvelope { + val bytes: PacketBytes + + @ConsistentCopyVisibility + data class Sequenced internal constructor( + override val bytes: PacketBytes, + /** Wire values: the high bits still contain the reliable payload/ack flags. */ + val sequence: Int, + val sequenceAck: Int, + /** Only client-to-server packets carry a qport. */ + val qport: Int?, + ) : PacketEnvelope { + val headerSize: Int get() = if (qport == null) 8 else 10 + } + + @ConsistentCopyVisibility + data class Connectionless internal constructor( + override val bytes: PacketBytes, + val commandLine: String, + /** Remaining text for server info/print replies; absent for other commands. */ + val parameters: String?, + ) : PacketEnvelope +} + +sealed interface PacketDecodeResult { + data class Decoded(val envelope: PacketEnvelope) : PacketDecodeResult + data class Rejected(val reason: PacketRejection) : PacketDecodeResult +} + +enum class PacketRejection { + OVERSIZED, + TRUNCATED_HEADER, + EMPTY_CONNECTIONLESS_COMMAND, +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index 585bffb1..d54d1aa1 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -2,6 +2,13 @@ import jake2.qcommon.exec.Cvar; import jake2.qcommon.network.messages.NetworkPacket; +import jake2.qcommon.network.messages.ConnectionlessCommand; +import jake2.qcommon.network.protocol.PacketCodec; +import jake2.qcommon.network.protocol.PacketDecodeResult; +import jake2.qcommon.network.protocol.PacketDirection; +import jake2.qcommon.network.transport.IpAddress; +import jake2.qcommon.network.transport.NetworkAddress; +import jake2.qcommon.network.transport.PacketBytes; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -52,8 +59,10 @@ void receivesWholeDatagramAtSizeBoundary(int length, boolean fromClient) throws @Test void socketPacketAcceptsAnExactlySizedArray() { byte[] bytes = sequencedPacket(MAX_MSGLEN, true); - NetworkPacket packet = NetworkPacket.fromSocket(true, bytes, bytes.length, - new byte[]{127, 0, 0, 1}, 27910); + var decoded = assertInstanceOf(PacketDecodeResult.Decoded.class, + PacketCodec.decode(PacketBytes.copyOf(bytes), PacketDirection.FROM_CLIENT)); + NetworkPacket packet = NetworkPacket.fromEnvelope( + new NetworkAddress.Udp(IpAddress.fromBytes(new byte[]{127, 0, 0, 1}), 27910), decoded.getEnvelope()); assertEquals(MAX_MSGLEN, packet.buffer.cursize); assertEquals(7, packet.sequence); @@ -71,10 +80,34 @@ void connectionlessStringEndsAtDatagramBoundaryWithoutTerminator() throws Except assertTrue(packet.isConnectionless()); assertEquals("x".repeat(MAX_MSGLEN - 4), packet.connectionlessMessage); - NetworkPacket loopback = NetworkPacket.fromLoopback(true, bytes, bytes.length); + NET.SendPacket(NS_CLIENT, bytes.length, bytes, new netadr_t()); + NetworkPacket loopback = NET.receiveLoopbackPacket(NS_SERVER); + assertNotNull(loopback); assertEquals(loopback.connectionlessMessage, packet.connectionlessMessage); } + @Test + void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { + NET.SendPacket(NS_CLIENT, 8, sequencedPacket(8, false), new netadr_t()); + Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + + assertNull(NET.receiveLoopbackPacket(NS_SERVER)); + NetworkPacket next = NET.receiveLoopbackPacket(NS_SERVER); + assertNotNull(next); + assertEquals("getchallenge", next.connectionlessMessage); + } + + @Test + void serverPrintReplySeparatesCommandFromMultilineParameters() { + Netchan.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.print, + "\nFirst line\nSecond line\n"); + + NetworkPacket packet = NET.receiveLoopbackPacket(NS_CLIENT); + assertNotNull(packet); + assertEquals("print", packet.connectionlessMessage); + assertEquals("First line\nSecond line\n", packet.connectionlessParameters); + } + private static byte[] sequencedPacket(int length, boolean fromClient) { byte[] bytes = new byte[length]; Arrays.fill(bytes, (byte) 0x5a); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index 160d76da..a1b41090 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -2,6 +2,12 @@ import jake2.qcommon.Globals; import jake2.qcommon.network.messages.NetworkPacket; +import jake2.qcommon.network.protocol.PacketCodec; +import jake2.qcommon.network.protocol.PacketDecodeResult; +import jake2.qcommon.network.protocol.PacketDirection; +import jake2.qcommon.network.transport.IpAddress; +import jake2.qcommon.network.transport.NetworkAddress; +import jake2.qcommon.network.transport.PacketBytes; import jake2.qcommon.sizebuf_t; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -51,6 +57,9 @@ private NetworkPacket packet(int sequence, int sequenceAck) { sizebuf_t buffer = new sizebuf_t(8); buffer.writeInt(sequence); buffer.writeInt(sequenceAck); - return NetworkPacket.fromLoopback(false, buffer.data, buffer.cursize); + var decoded = (PacketDecodeResult.Decoded) PacketCodec.decode( + PacketBytes.copyOf(buffer.data, buffer.cursize), PacketDirection.FROM_SERVER); + return NetworkPacket.fromEnvelope( + new NetworkAddress.Udp(IpAddress.fromBytes(new byte[]{127, 0, 0, 1}), 27910), decoded.getEnvelope()); } } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt new file mode 100644 index 00000000..702e3179 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -0,0 +1,157 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.MAX_MSGLEN +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.network.transport.IpAddress +import jake2.qcommon.network.transport.LocalSide +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import jake2.qcommon.usercmd_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import java.util.UUID + +/** Deliberately has no engine/Cvar initialization: envelope decoding is standalone. */ +class PacketCodecTest { + @ParameterizedTest + @EnumSource(PacketDirection::class) + fun `reject every truncated sequenced header`(direction: PacketDirection) { + for (length in 0 until direction.sequencedHeaderSize) { + val bytes = ByteArray(length) + if (length > 0) bytes[0] = 1 + assertEquals(PacketDecodeResult.Rejected(PacketRejection.TRUNCATED_HEADER), + PacketCodec.decode(PacketBytes.copyOf(bytes), direction), "length=$length, direction=$direction") + } + } + + @Test + fun `short ff prefix cannot masquerade as a connectionless packet`() { + for (length in 0..3) { + assertEquals(PacketDecodeResult.Rejected(PacketRejection.TRUNCATED_HEADER), + PacketCodec.decode(PacketBytes.copyOf(ByteArray(length) { -1 }), PacketDirection.FROM_CLIENT)) + } + for (suffix in listOf(byteArrayOf(), byteArrayOf(0), byteArrayOf(10), byteArrayOf(-1))) { + assertEquals(PacketDecodeResult.Rejected(PacketRejection.EMPTY_CONNECTIONLESS_COMMAND), + PacketCodec.decode(PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + suffix), PacketDirection.FROM_CLIENT)) + } + } + + @ParameterizedTest + @EnumSource(PacketDirection::class) + fun `decode little endian sequence flags without consuming body bytes`(direction: PacketDirection) { + val header = byteArrayOf(0x78, 0x56, 0x34, 0x92.toByte(), 0xef.toByte(), 0xcd.toByte(), 0xab.toByte(), 0x89.toByte()) + val qport = if (direction == PacketDirection.FROM_CLIENT) byteArrayOf(0xfe.toByte(), 0xff.toByte()) else byteArrayOf() + val wire = header + qport + byteArrayOf(0x23, 0x45) + val envelope = assertInstanceOf(PacketEnvelope.Sequenced::class.java, decode(wire, direction)) + + assertEquals(0x92345678.toInt(), envelope.sequence) + assertEquals(0x89abcdef.toInt(), envelope.sequenceAck) + assertEquals(if (direction == PacketDirection.FROM_CLIENT) 65534 else null, envelope.qport) + assertEquals(direction.sequencedHeaderSize, envelope.headerSize) + assertArrayEquals(wire, envelope.bytes.toByteArray()) + + val source = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), 27910) + val packet = NetworkPacket.fromEnvelope(source, envelope) + assertEquals(envelope.sequence, packet.sequence) + assertEquals(envelope.sequenceAck, packet.sequenceAck) + assertEquals(wire.size, packet.buffer.cursize) + assertEquals(direction.sequencedHeaderSize, packet.buffer.readcount) + assertEquals(0x23, packet.buffer.readByte()) + assertEquals(0x45, packet.buffer.readByte()) + assertEquals(27910, packet.from.port) + packet.buffer.data.fill(0) + packet.sequence = 0 + assertArrayEquals(wire, envelope.bytes.toByteArray()) + assertEquals(0x92345678.toInt(), envelope.sequence) + } + + @ParameterizedTest + @EnumSource(PacketDirection::class) + fun `header-only sequenced packets are valid`(direction: PacketDirection) { + val bytes = ByteArray(direction.sequencedHeaderSize) + bytes[0] = 1 + val envelope = assertInstanceOf(PacketEnvelope.Sequenced::class.java, decode(bytes, direction)) + assertEquals(1, envelope.sequence) + assertEquals(if (direction == PacketDirection.FROM_CLIENT) 0 else null, envelope.qport) + } + + @ParameterizedTest + @ValueSource(strings = ["\n", "\u0000"]) + fun `server info and print replies preserve multiline text after command separator`(separator: String) { + for (command in listOf("info", "print")) { + val envelope = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("$command${separator}Café\nsecond line\n"), PacketDirection.FROM_SERVER)) + assertEquals(command, envelope.commandLine) + assertEquals("Café\nsecond line\n", envelope.parameters) + } + } + + @Test + fun `connectionless commands accept datagram boundary and legacy terminators`() { + for (suffix in listOf(byteArrayOf(), byteArrayOf(0), byteArrayOf(-1), byteArrayOf(10))) { + val envelope = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("challenge 123") + suffix, PacketDirection.FROM_SERVER)) + assertEquals("challenge 123", envelope.commandLine) + assertNull(envelope.parameters) + } + val request = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("info 34\n"), PacketDirection.FROM_CLIENT)) + assertEquals("info 34", request.commandLine) + assertNull(request.parameters) + val unknown = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("future_command data"), PacketDirection.FROM_CLIENT)) + assertEquals("future_command data", unknown.commandLine) + } + + @Test + fun `text payload stops at terminator and empty print body stays empty`() { + val envelope = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("print\nhello\u0000ignored"), PacketDirection.FROM_SERVER)) + assertEquals("hello", envelope.parameters) + val empty = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(connectionless("print"), PacketDirection.FROM_SERVER)) + assertEquals("", empty.parameters) + } + + @Test + fun `full sized datagram is valid but oversized input is rejected`() { + val wire = connectionless("x".repeat(MAX_MSGLEN - 4)) + val envelope = assertInstanceOf(PacketEnvelope.Connectionless::class.java, + decode(wire, PacketDirection.FROM_CLIENT)) + assertEquals(MAX_MSGLEN - 4, envelope.commandLine.length) + assertArrayEquals(wire, envelope.bytes.toByteArray()) + assertEquals(PacketDecodeResult.Rejected(PacketRejection.OVERSIZED), + PacketCodec.decode(PacketBytes.copyOf(wire + byteArrayOf(1)), PacketDirection.FROM_CLIENT)) + } + + @Test + fun `legacy body reader retains movement checksum offsets`() { + val sequence = 17 + val newest = usercmd_t().apply { forwardmove = 120; msec = 16 } + val movement = MoveMessage(false, 12, usercmd_t(), usercmd_t(), newest, sequence) + val buffer = sizebuf_t(MAX_MSGLEN) + buffer.writeInt(sequence) + buffer.writeInt(5) + buffer.writeShort(42) + movement.writeTo(buffer) + + val envelope = decode(buffer.data.copyOf(buffer.cursize), PacketDirection.FROM_CLIENT) + val packet = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), envelope) + val parsed = assertInstanceOf(MoveMessage::class.java, packet.parseBodyFromClient(sequence).single()) + assertTrue(parsed.valid) + assertEquals(12, parsed.lastReceivedFrame) + assertEquals(120, parsed.newCmd.forwardmove.toInt()) + assertEquals(16, parsed.newCmd.msec.toInt()) + } + + private fun connectionless(text: String): ByteArray = + byteArrayOf(-1, -1, -1, -1) + text.toByteArray(Charsets.ISO_8859_1) + + private fun decode(bytes: ByteArray, direction: PacketDirection): PacketEnvelope = + assertInstanceOf(PacketDecodeResult.Decoded::class.java, PacketCodec.decode(PacketBytes.copyOf(bytes), direction)).envelope +} From 25013a34ecc2d4cad09cf0aabc305202edc00985 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 23:17:34 +0100 Subject: [PATCH 20/57] Extract deterministic reliable channel with transactional sends --- .../src/main/kotlin/org/demoth/cake/Cake.kt | 5 +- .../main/java/jake2/qcommon/network/NET.java | 16 +- .../network/messages/NetworkPacket.java | 7 + .../java/jake2/qcommon/network/netchan_t.java | 294 +++++++----------- .../qcommon/network/protocol/PacketCodec.kt | 16 +- .../network/protocol/ReliableChannel.kt | 216 +++++++++++++ .../jake2/qcommon/network/NetchanTest.java | 72 ++++- .../network/protocol/PacketCodecTest.kt | 2 + .../network/protocol/ReliableChannelTest.kt | 250 +++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 6 +- 10 files changed, 676 insertions(+), 208 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt 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 694737b9..85ab2e30 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -540,8 +540,9 @@ class Cake( } private fun transmitAndRecord(unreliable: Collection?) { - netchan.transmit(unreliable) - networkDebugSampler.recordOutbound(netchan.last_sent_size, Globals.curtime) + if (netchan.transmit(unreliable)) { + networkDebugSampler.recordOutbound(netchan.last_sent_size, Globals.curtime) + } } private fun handleMissingResourceFailure(error: MissingResourceException) { diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index b2844d09..34ad51df 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -36,6 +36,7 @@ import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.nio.channels.ClosedChannelException; import static jake2.qcommon.Defines.NS_SERVER; @@ -96,25 +97,30 @@ private static NetworkPacket adaptReceived(ReceiveResult result, boolean fromCli return packet; } - public static void SendPacket(int role, int length, byte[] data, netadr_t to) { + public static SendResult SendPacket(int role, int length, byte[] data, netadr_t to) { + return sendPacket(role, PacketBytes.copyOf(data, length), to); + } + + public static SendResult sendPacket(int role, PacketBytes payload, netadr_t to) { if (to.type == NetAddrType.NA_LOOPBACK) { - SendLoopPacket(role, length, data, to); - return; + return localEndpoint(role).send(localEndpoint(role ^ 1).getLocalAddress(), payload); } UdpDatagramEndpoint endpoint = udpEndpoints[role]; - if (endpoint == null) return; + if (endpoint == null) return new SendResult.Failed(new ClosedChannelException()); if (to.type != NetAddrType.NA_BROADCAST && to.type != NetAddrType.NA_IP) { throw new IllegalArgumentException("NET_SendPacket: unsupported address type " + to.type); } try { NetworkAddress.Udp destination = new NetworkAddress.Udp( IpAddress.fromInetAddress(to.getInetAddress()), to.port); - SendResult result = endpoint.send(destination, PacketBytes.copyOf(data, length)); + SendResult result = endpoint.send(destination, payload); if (result instanceof SendResult.Failed failure) { Com.Println("NET_SendPacket ERROR: " + failure.getCause() + " to " + to); } + return result; } catch (IOException | IllegalArgumentException error) { Com.Println("NET_SendPacket ERROR: " + error + " to " + to); + return new SendResult.Failed(error instanceof IOException io ? io : new IOException(error)); } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index cae29e4d..fd768b41 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -18,6 +18,7 @@ import java.util.List; public class NetworkPacket { + private PacketEnvelope envelope; // Long, -1 for connectionless public int sequence; @@ -41,6 +42,7 @@ public class NetworkPacket { /** Adapt a validated envelope to the mutable legacy channel/message reader. */ public static NetworkPacket fromEnvelope(NetworkAddress source, PacketEnvelope envelope) { NetworkPacket packet = new NetworkPacket(); + packet.envelope = envelope; packet.buffer.data = envelope.getBytes().toByteArray(); packet.buffer.cursize = packet.buffer.data.length; packet.buffer.maxsize = packet.buffer.data.length; @@ -70,6 +72,11 @@ public boolean isConnectionless() { return sequence == -1; } + public PacketEnvelope.Sequenced sequencedEnvelope() { + if (envelope instanceof PacketEnvelope.Sequenced sequenced) return sequenced; + throw new IllegalStateException("Connectionless packet has no channel header"); + } + public Collection parseBodyFromServer() { List result = new ArrayList<>(); while (true) { diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java index fecfe2da..a33a9484 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java @@ -27,245 +27,159 @@ import jake2.qcommon.Globals; import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.network.messages.NetworkPacket; +import jake2.qcommon.network.protocol.*; +import jake2.qcommon.network.transport.PacketBytes; +import jake2.qcommon.network.transport.SendResult; import jake2.qcommon.sizebuf_t; import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; -import java.util.Map; +import java.util.function.LongSupplier; +/** Legacy engine adapter. Protocol state and owned reliable bytes live in ReliableChannel. */ public class netchan_t { - public boolean fatal_error; - - // was enum {NS_CLIENT, NS_SERVER} public int sock; - - public int dropped; // between last packet and previous - - public int last_received; // for timeouts - - public int last_sent; // for retransmits - public netadr_t remote_address = new netadr_t(); + public int qport; - public int qport; // qport value to write when transmitting - - // sequencing variables + // Compatibility snapshots for existing engine consumers; never used as protocol storage. + public int dropped; + public int last_received; + public int last_sent; public int incoming_sequence; - public int incoming_acknowledged; - - public int incoming_reliable_acknowledged; // single bit - - // single bit, maintained locally, switched every time a reliable message is received. + public int incoming_reliable_acknowledged; public int reliable_received_flag; - // single bit, maintained locally, switched every time a reliable message is sent public int reliable_sent_flag; - - public int outgoing_sequence; - - public int last_reliable_sequence; // sequence number of last send - + public int outgoing_sequence = 1; + public int last_reliable_sequence; public int last_received_size; - public int last_sent_size; - public int smoothed_ping_ms; - private final Map sendTimesBySequence = new HashMap<>(); + /** Engine message staging remains here until session controllers own message encoding. */ + public final Collection reliablePending = new ArrayList<>(); + + private final LongSupplier clockMillis; + private ReliableChannel channel = new ReliableChannel(ChannelRole.CLIENT, 0); - /** - * Reliable staging areas, appended during the frame update. - * Overall size of the messages in bytes should not exceed Defines.MAX_MSGLEN - 16 (some space for header is reserved), otherwise connection will be closed. - * See @{link jake2.qcommon.network.Netchan#Transmit} - */ - public Collection reliablePending = new ArrayList<>(); + public netchan_t() { + this(() -> System.nanoTime() / 1_000_000L); + } - // message is copied to this buffer when it is first transfered - public sizebuf_t reliableUnacknowledged = new sizebuf_t(Defines.MAX_MSGLEN - 16); + public netchan_t(LongSupplier clockMillis) { + this.clockMillis = clockMillis; + } - /** - * Netchan_Setup is called to open a channel to a remote system. - */ public void setup(int sock, netadr_t adr, int qport) { clear(); this.sock = sock; remote_address.set(adr); this.qport = qport; + channel = new ReliableChannel(sock == Defines.NS_CLIENT ? ChannelRole.CLIENT : ChannelRole.SERVER, + qport & 0xffff); last_received = Globals.curtime; - incoming_sequence = 0; - outgoing_sequence = 1; + syncState(); } - // Netchan.Process public boolean accept(NetworkPacket packet) { - // unpack sequences and flags - // achtung unsigned int - boolean containsReliable = (packet.sequence >>> 31) != 0; - int reliable_ack = packet.sequenceAck >>> 31; // single bit - packet.sequence &= ~(1 << 31); - packet.sequenceAck &= ~(1 << 31); - - // - // discard stale or duplicated packets - // - if (packet.sequence <= incoming_sequence) { - return false; - } - - // - // dropped packets don't keep the message from being used - // - dropped = packet.sequence - (incoming_sequence + 1); - - // - // if the current outgoing reliable message has been acknowledged - // clear the buffer to make way for the next - // - if (reliable_ack == reliable_sent_flag) - reliableUnacknowledged.clear(); // it has been received - - // - // if this message contains a reliable message, bump - // incoming_reliable_sequence - // - incoming_sequence = packet.sequence; - incoming_acknowledged = packet.sequenceAck; - incoming_reliable_acknowledged = reliable_ack; - last_received_size = packet.buffer.cursize; - Integer sentAt = sendTimesBySequence.remove(packet.sequenceAck); - if (sentAt != null) { - int measuredPing = Math.max(0, Globals.curtime - sentAt); - if (smoothed_ping_ms == 0) { - smoothed_ping_ms = measuredPing; - } else { - smoothed_ping_ms = (smoothed_ping_ms * 3 + measuredPing) / 4; - } - sendTimesBySequence.entrySet().removeIf(entry -> entry.getKey() < packet.sequenceAck - 32); - } - if (containsReliable) { - reliable_received_flag ^= 1; - } - - // - // the message can now be read from the current message pointer - // - last_received = (int) Globals.curtime; + if (packet.isConnectionless()) return false; + ChannelAcceptResult result = channel.accept(packet.sequencedEnvelope(), clockMillis.getAsLong()); + // Keep the legacy message-processing view free of the reliable flag bits. + packet.sequence &= Integer.MAX_VALUE; + packet.sequenceAck &= Integer.MAX_VALUE; + if (!(result instanceof ChannelAcceptResult.Accepted)) return false; + last_received = Globals.curtime; + syncState(); return true; } - - /** - * `Netchan_Transmit` tries to send an unreliable message to a connection, - * and handles the transmition / retransmition of the reliable messages. - * - * Reliable messages are kept in two separate areas - pending and unacknowledged. - * Pending reliable messages are accumulated during the frame update. - * Unacknowledged reliable messages - the messages that were already sent but not yet confirmed by the other side. - * Once the messages become acknowledged, all pending messages are copied to the unacknowledged buffer and the process repeats. - * - * @param unreliable An empty or null collection will still generate a packet and deal with the reliable - * messages. - */ - public void transmit(Collection unreliable) { - - // check for message overflow - int pendingReliableSize = reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { + /** Returns true only after the transport accepted the datagram. */ + public boolean transmit(Collection unreliable) { + if (reliablePending.stream().mapToLong(NetworkMessage::getSize).sum() > Defines.MAX_MSGLEN - 16) { fatal_error = true; - Com.Printf(remote_address.toString() + ":Outgoing message overflow\n"); - return; - } - - // single bit - int send_reliable = needReliable() ? 1 : 0; - - // If the last reliable message was acknowledged by the client (jake2.qcommon.network.netchan_t.accept), - // send next portion of pending reliable messages (if there is something to send). - // Otherwise, the contents of the reliableUnacknowledged will be retransmitted - if (reliableUnacknowledged.cursize == 0 && reliablePending.size() != 0) { - reliablePending.forEach(networkMessage -> networkMessage.writeTo(reliableUnacknowledged)); - reliablePending.clear(); - reliable_sent_flag ^= 1; - } - - sizebuf_t packet = new sizebuf_t(Defines.MAX_MSGLEN); - - // write the packet header - int sequence = (outgoing_sequence & ~(1 << 31)) | (send_reliable << 31); - int sequenceAck = (incoming_sequence & ~(1 << 31)) | (reliable_received_flag << 31); - - outgoing_sequence++; - last_sent = (int) Globals.curtime; - - // write header - packet.writeInt(sequence); - packet.writeInt(sequenceAck); - - // send the qport if we are a client - if (sock == Defines.NS_CLIENT) - packet.writeShort(Netchan.qport.getTypedValue()); - - // copy the reliable message to the packet first - if (send_reliable != 0) { - packet.writeBytes(reliableUnacknowledged.data, reliableUnacknowledged.cursize); - last_reliable_sequence = outgoing_sequence; + Com.Printf(remote_address + ":Outgoing message overflow\n"); + return false; } - - if (unreliable != null) { - // add the unreliable part if space is available - int length = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); - if (packet.maxsize - packet.cursize >= length) { - unreliable.forEach(msg -> msg.writeTo(packet)); - } else { - Com.Printf("Netchan_Transmit: dumped unreliable\n"); + boolean staged = false; + boolean sent = false; + try { + if (channel.getCanSendReliable() && !reliablePending.isEmpty()) { + PacketBytes reliable = encodeBatch(reliablePending, Defines.MAX_MSGLEN - 16); + if (channel.enqueueReliable(reliable) != ReliableQueueResult.QUEUED) { + fatal_error = true; + return false; + } + staged = true; } + PrepareSendResult result = channel.prepareSend((sequence, capacity) -> encodeBatch(unreliable, capacity)); + if (result instanceof PrepareSendResult.SequenceExhausted) { + fatal_error = true; + return false; + } + PrepareSendResult.Prepared transmission = (PrepareSendResult.Prepared) result; + try { + sent = NET.sendPacket(sock, transmission.getBytes(), remote_address) instanceof SendResult.Sent; + } finally { + channel.completeSend(transmission, sent, clockMillis.getAsLong()); + } + if (sent) { + last_sent = Globals.curtime; + if (transmission.getUnreliableDropped()) Com.Printf("Netchan_Transmit: dumped unreliable\n"); + if (Netchan.showpackets != null && Netchan.showpackets.getTypedValue()) { + Com.Printf(String.format("Netchan: %s | out: %6d | in: %6d | outr: %1d | inr: %1d \n", + sock == Defines.NS_CLIENT ? "client" : "server", transmission.getSequence(), + channel.getIncomingSequence(), transmission.getContainsReliable() ? 1 : 0, + channel.getReliableReceivedFlag())); + } + } + return sent; + } finally { + if (staged) { + if (sent) reliablePending.clear(); + else channel.discardPending(); // Leave unsent message staging available to existing cancellation paths. + } + syncState(); } + } - last_sent_size = packet.cursize; - sendTimesBySequence.put(outgoing_sequence - 1, last_sent); - - // send the datagram - NET.SendPacket(sock, packet.cursize, packet.data, remote_address); - - if (Netchan.showpackets.getTypedValue()) { - Com.Printf(String.format("Netchan: %s | out: %6d | in: %6d | outr: %1d | inr: %1d \n", sock == 0 ? "client" : "server", outgoing_sequence - 1, incoming_sequence, send_reliable, reliable_received_flag)); - } - + private static PacketBytes encodeBatch(Collection messages, int capacity) { + if (messages == null || messages.isEmpty()) return PacketBytes.copyOf(new byte[0]); + if (messages.stream().mapToLong(NetworkMessage::getSize).sum() > capacity) return null; + sizebuf_t buffer = new sizebuf_t(capacity); + messages.forEach(message -> message.writeTo(buffer)); + return PacketBytes.copyOf(buffer.data, buffer.cursize); } - /** - * Netchan_CanReliable. Returns true if the last reliable message has acked. - */ public boolean canReliable() { - return reliableUnacknowledged.cursize == 0; // waiting for ack + return channel.getCanSendReliable(); } - /** - * Netchan_NeedReliable - */ public boolean needReliable() { - // if the remote side dropped the last reliable message, resend it - boolean send_reliable = incoming_acknowledged > last_reliable_sequence && incoming_reliable_acknowledged != reliable_sent_flag; - - // if the reliable transmit buffer is empty, copy the current message out - if (reliableUnacknowledged.cursize == 0 && reliablePending.size() != 0) { - send_reliable = true; - } - - return send_reliable; + return channel.getNeedsReliable() || (channel.getCanSendReliable() && !reliablePending.isEmpty()); } - //ok. public void clear() { - sock = dropped = last_received = last_sent = 0; + fatal_error = false; + sock = qport = last_received = last_sent = 0; remote_address = new netadr_t(); - qport = incoming_sequence = incoming_acknowledged = incoming_reliable_acknowledged = reliable_received_flag = outgoing_sequence = reliable_sent_flag = last_reliable_sequence = 0; - last_received_size = last_sent_size = smoothed_ping_ms = 0; reliablePending.clear(); - reliableUnacknowledged.clear(); - sendTimesBySequence.clear(); + channel = new ReliableChannel(ChannelRole.CLIENT, 0); + syncState(); } + private void syncState() { + dropped = channel.getDropped(); + incoming_sequence = channel.getIncomingSequence(); + incoming_acknowledged = channel.getIncomingAcknowledged(); + incoming_reliable_acknowledged = channel.getIncomingReliableAcknowledged(); + reliable_received_flag = channel.getReliableReceivedFlag(); + reliable_sent_flag = channel.getReliableSentFlag(); + outgoing_sequence = channel.getOutgoingSequence(); + last_reliable_sequence = channel.getLastReliableSequence(); + last_received_size = channel.getLastReceivedSize(); + last_sent_size = channel.getLastSentSize(); + smoothed_ping_ms = channel.getSmoothedPingMillis(); + } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt index 4fd93c07..398501a9 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt @@ -5,8 +5,22 @@ import jake2.qcommon.network.transport.PacketBytes import java.nio.ByteBuffer import java.nio.ByteOrder -/** Stateless envelope decoding. No sockets, Cvars, logging, or mutable channel state. */ +/** Stateless envelope encoding/decoding. No sockets, Cvars, logging, or mutable channel state. */ object PacketCodec { + /** Encode the wire header and already-serialized body without changing channel state. */ + @JvmStatic + fun encodeSequenced(sequence: Int, sequenceAck: Int, qport: Int?, payload: PacketBytes): PacketBytes { + require(sequence != -1) { "Sequence -1 is reserved for connectionless packets" } + require(qport == null || qport in 0..65535) { "Qport out of range" } + val headerSize = if (qport == null) 8 else 10 + require(payload.size <= Defines.MAX_MSGLEN - headerSize) { "Packet exceeds protocol limit" } + val writer = ByteBuffer.allocate(headerSize + payload.size).order(ByteOrder.LITTLE_ENDIAN) + writer.putInt(sequence).putInt(sequenceAck) + if (qport != null) writer.putShort(qport.toShort()) + writer.put(payload.readOnlyBuffer()) + return PacketBytes.copyOf(writer.array()) + } + @JvmStatic fun decode(bytes: PacketBytes, direction: PacketDirection): PacketDecodeResult { if (bytes.size > Defines.MAX_MSGLEN) return PacketDecodeResult.Rejected(PacketRejection.OVERSIZED) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt new file mode 100644 index 00000000..02c33484 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt @@ -0,0 +1,216 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines +import jake2.qcommon.network.transport.PacketBytes + +enum class ChannelRole(val headerSize: Int) { CLIENT(10), SERVER(8) } + +data class ChannelLimits @JvmOverloads constructor( + val maxPacketSize: Int = Defines.MAX_MSGLEN, + val maxReliableBytes: Int = Defines.MAX_MSGLEN - 16, + val rttHistorySize: Int = 64, +) { + init { + require(maxPacketSize in 10..Defines.MAX_MSGLEN) + require(maxReliableBytes in 1..maxPacketSize - 10) + require(rttHistorySize > 0) + } +} + +enum class ReliableQueueResult { QUEUED, FULL } +enum class ChannelRejection { OUT_OF_ORDER, WRONG_DIRECTION } + +sealed interface ChannelAcceptResult { + data object Accepted : ChannelAcceptResult + data class Rejected(val reason: ChannelRejection) : ChannelAcceptResult +} + +/** Called with the reserved sequence (needed for movement checksums) and remaining packet space. + * Return null to omit an unreliable batch that cannot fit; an empty payload means no batch. + */ +fun interface UnreliableEncoder { + fun encode(sequence: Int, capacity: Int): PacketBytes? +} + +sealed interface PrepareSendResult { + data object SequenceExhausted : PrepareSendResult + + class Prepared internal constructor( + val sequence: Int, + val bytes: PacketBytes, + val containsReliable: Boolean, + val unreliableDropped: Boolean, + internal val promotedReliable: PacketBytes?, + internal val reliableFlag: Int, + ) : PrepareSendResult +} + +/** Quake II reliable channel state, confined to one owner thread. + * Times are elapsed monotonic milliseconds supplied by the caller. Every prepared + * transmission must be completed before further channel operations, even on I/O failure. + */ +class ReliableChannel @JvmOverloads constructor( + private val role: ChannelRole, + private val qport: Int, + private val limits: ChannelLimits = ChannelLimits(), +) { + init { require(qport in 0..65535) } + + var outgoingSequence = 1 + private set + var incomingSequence = 0 + private set + var incomingAcknowledged = 0 + private set + var incomingReliableAcknowledged = 0 + private set + var reliableReceivedFlag = 0 + private set + var reliableSentFlag = 0 + private set + var lastReliableSequence = 0 + private set + var dropped = 0 + private set + var lastReceivedMillis = 0L + private set + var lastSentMillis = 0L + private set + var lastReceivedSize = 0 + private set + var lastSentSize = 0 + private set + var smoothedPingMillis = 0 + private set + + private val pending = ArrayDeque() + var pendingReliableBytes = 0 + private set + private var unacknowledged: PacketBytes? = null + private var prepared: PrepareSendResult.Prepared? = null + private var encoding = false + private val sendTimes = LinkedHashMap() + + val unacknowledgedReliableBytes: Int get() = unacknowledged?.size ?: 0 + val trackedSendCount: Int get() = sendTimes.size + val canSendReliable: Boolean get() = unacknowledged == null + val needsReliable: Boolean get() = + (unacknowledged == null && pendingReliableBytes > 0) || + (unacknowledged != null && incomingAcknowledged > lastReliableSequence && + incomingReliableAcknowledged != reliableSentFlag) + + fun enqueueReliable(payload: PacketBytes): ReliableQueueResult { + checkIdle() + if (payload.size > limits.maxReliableBytes - pendingReliableBytes) return ReliableQueueResult.FULL + if (payload.size > 0) { + pending.addLast(payload) + pendingReliableBytes += payload.size + } + return ReliableQueueResult.QUEUED + } + + /** Cancel only unsent work; already-transmitted reliable bytes still require acknowledgement. */ + fun discardPending() { + checkIdle() + pending.clear() + pendingReliableBytes = 0 + } + + fun accept(packet: PacketEnvelope.Sequenced, nowMillis: Long): ChannelAcceptResult { + checkIdle() + if ((packet.qport == null) != (role == ChannelRole.CLIENT)) { + return ChannelAcceptResult.Rejected(ChannelRejection.WRONG_DIRECTION) + } + val sequence = packet.sequence and Int.MAX_VALUE + val acknowledgement = packet.sequenceAck and Int.MAX_VALUE + if (sequence <= incomingSequence) return ChannelAcceptResult.Rejected(ChannelRejection.OUT_OF_ORDER) + + val reliableAck = packet.sequenceAck ushr 31 + dropped = sequence - incomingSequence - 1 + // Preserve the protocol's one-bit reliable acknowledgement rule. + if (reliableAck == reliableSentFlag) unacknowledged = null + incomingSequence = sequence + incomingAcknowledged = acknowledgement + incomingReliableAcknowledged = reliableAck + if (packet.sequence ushr 31 != 0) reliableReceivedFlag = reliableReceivedFlag xor 1 + lastReceivedMillis = nowMillis + lastReceivedSize = packet.bytes.size + + sendTimes.remove(acknowledgement)?.let { sentAt -> + val measured = (nowMillis - sentAt).coerceIn(0, Int.MAX_VALUE.toLong()).toInt() + smoothedPingMillis = if (smoothedPingMillis == 0) measured + else ((smoothedPingMillis.toLong() * 3 + measured) / 4).toInt() + sendTimes.keys.removeIf { it < acknowledgement - 32 } + } + return ChannelAcceptResult.Accepted + } + + fun prepareSend(unreliable: PacketBytes): PrepareSendResult = + prepareSend { _, capacity -> unreliable.takeIf { it.size <= capacity } } + + fun prepareSend(encoder: UnreliableEncoder): PrepareSendResult { + checkIdle() + encoding = true + try { + return preparePacket(encoder) + } finally { + encoding = false + } + } + + private fun preparePacket(encoder: UnreliableEncoder): PrepareSendResult { + // Do not wrap into an old sequence or emit the connectionless marker with a reliable flag. + if (outgoingSequence == Int.MAX_VALUE) return PrepareSendResult.SequenceExhausted + val promoted = if (canSendReliable && pendingReliableBytes > 0) join(pending, pendingReliableBytes) else null + val containsReliable = needsReliable + val reliable = if (containsReliable) promoted ?: unacknowledged else null + val reliableFlag = if (promoted != null) reliableSentFlag xor 1 else reliableSentFlag + val capacity = limits.maxPacketSize - role.headerSize - (reliable?.size ?: 0) + val unreliable = encoder.encode(outgoingSequence, capacity) + require(unreliable == null || unreliable.size <= capacity) { "Unreliable encoder exceeded its capacity" } + val body = join(listOfNotNull(reliable, unreliable), (reliable?.size ?: 0) + (unreliable?.size ?: 0)) + val bytes = PacketCodec.encodeSequenced( + outgoingSequence or (if (containsReliable) Int.MIN_VALUE else 0), + incomingSequence or (reliableReceivedFlag shl 31), + if (role == ChannelRole.CLIENT) qport else null, + body, + ) + return PrepareSendResult.Prepared(outgoingSequence, bytes, containsReliable, unreliable == null, + promoted, reliableFlag).also { prepared = it } + } + + /** sent=false covers WouldBlock, transport failure, or cancellation before sending. */ + fun completeSend(transmission: PrepareSendResult.Prepared, sent: Boolean, nowMillis: Long) { + check(prepared === transmission) { "Transmission is not this channel's active send" } + prepared = null + if (!sent) return + + transmission.promotedReliable?.let { + unacknowledged = it + reliableSentFlag = transmission.reliableFlag + discardPending() + } + outgoingSequence++ + // Legacy retransmit threshold is the sequence *after* the reliable transmission. + if (transmission.containsReliable) lastReliableSequence = outgoingSequence + lastSentMillis = nowMillis + lastSentSize = transmission.bytes.size + sendTimes[transmission.sequence] = nowMillis + while (sendTimes.size > limits.rttHistorySize) sendTimes.remove(sendTimes.keys.first()) + } + + private fun checkIdle() = check(prepared == null && !encoding) { + "Finish encoding and complete the prepared send before changing channel state" + } + + private fun join(parts: Collection, size: Int): PacketBytes { + val bytes = ByteArray(size) + var offset = 0 + for (part in parts) { + val data = part.toByteArray() + data.copyInto(bytes, offset) + offset += data.size + } + return PacketBytes.copyOf(bytes) + } +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index a1b41090..2b68794d 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -1,6 +1,8 @@ package jake2.qcommon.network; import jake2.qcommon.Globals; +import jake2.qcommon.exec.Cvar; +import jake2.qcommon.network.messages.client.StringCmdMessage; import jake2.qcommon.network.messages.NetworkPacket; import jake2.qcommon.network.protocol.PacketCodec; import jake2.qcommon.network.protocol.PacketDecodeResult; @@ -10,23 +12,78 @@ import jake2.qcommon.network.transport.PacketBytes; import jake2.qcommon.sizebuf_t; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import static jake2.qcommon.Defines.NS_SERVER; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static jake2.qcommon.Defines.*; +import static org.junit.jupiter.api.Assertions.*; class NetchanTest { @BeforeEach void setUp() { + NET.Config(false); + NET.clearLoopbacks(); + Cvar.getInstance().clear(); Globals.curtime = 0; Netchan.Netchan_Init(); } + @AfterEach + void cleanup() { + NET.Config(false); + NET.clearLoopbacks(); + Cvar.getInstance().clear(); + } + + @Test + void outgoingClientPacketUsesItsSetupQport() { + Netchan.qport.forceSet(77); + var channel = new netchan_t(); + channel.setup(NS_CLIENT, new netadr_t(), 1234); + assertTrue(channel.transmit(null)); + assertEquals(1234, NET.receiveLoopbackPacket(NS_SERVER).qport); + } + + @Test + void unavailableTransportDoesNotAdvanceOrLoseStagedMessages() { + var channel = new netchan_t(() -> Globals.curtime); + var remote = new netadr_t(); + remote.type = NetAddrType.NA_IP; + remote.port = 27910; + channel.setup(NS_CLIENT, remote, 7); + channel.reliablePending.add(new StringCmdMessage("new")); + Globals.curtime = 100; + assertFalse(channel.transmit(null)); + assertEquals(1, channel.outgoing_sequence); + assertEquals(1, channel.reliablePending.size()); + assertEquals(0, channel.last_sent_size); + assertEquals(0, channel.last_sent); + assertEquals(0, channel.reliable_sent_flag); + + channel.remote_address = new netadr_t(); + assertTrue(channel.transmit(null)); + assertEquals(2, channel.outgoing_sequence); + assertTrue(channel.reliablePending.isEmpty()); + assertEquals(100, channel.last_sent); + } + + @Test + void unexpectedSendFailureReleasesPreparedTransaction() { + var channel = new netchan_t(); + channel.setup(NS_CLIENT, new netadr_t(), 7); + channel.reliablePending.add(new StringCmdMessage("new")); + channel.remote_address = null; + assertThrows(NullPointerException.class, () -> channel.transmit(null)); + assertEquals(1, channel.outgoing_sequence); + assertEquals(1, channel.reliablePending.size()); + channel.remote_address = new netadr_t(); + assertTrue(channel.transmit(null)); + } + @Test void recordsSentPacketSizeAfterPacketIsBuilt() { - netchan_t netchan = new netchan_t(); + netchan_t netchan = new netchan_t(() -> Globals.curtime); netchan.setup(NS_SERVER, new netadr_t(), 0); Globals.curtime = 100; @@ -37,7 +94,7 @@ void recordsSentPacketSizeAfterPacketIsBuilt() { @Test void smoothsPingFromAcknowledgedPacketSequence() { - netchan_t netchan = new netchan_t(); + netchan_t netchan = new netchan_t(() -> Globals.curtime); netchan.setup(NS_SERVER, new netadr_t(), 0); Globals.curtime = 100; @@ -54,11 +111,12 @@ void smoothsPingFromAcknowledgedPacketSequence() { } private NetworkPacket packet(int sequence, int sequenceAck) { - sizebuf_t buffer = new sizebuf_t(8); + sizebuf_t buffer = new sizebuf_t(10); buffer.writeInt(sequence); buffer.writeInt(sequenceAck); + buffer.writeShort(0); var decoded = (PacketDecodeResult.Decoded) PacketCodec.decode( - PacketBytes.copyOf(buffer.data, buffer.cursize), PacketDirection.FROM_SERVER); + PacketBytes.copyOf(buffer.data, buffer.cursize), PacketDirection.FROM_CLIENT); return NetworkPacket.fromEnvelope( new NetworkAddress.Udp(IpAddress.fromBytes(new byte[]{127, 0, 0, 1}), 27910), decoded.getEnvelope()); } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt index 702e3179..6894c26b 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -54,6 +54,8 @@ class PacketCodecTest { assertEquals(if (direction == PacketDirection.FROM_CLIENT) 65534 else null, envelope.qport) assertEquals(direction.sequencedHeaderSize, envelope.headerSize) assertArrayEquals(wire, envelope.bytes.toByteArray()) + assertArrayEquals(wire, PacketCodec.encodeSequenced(envelope.sequence, envelope.sequenceAck, + envelope.qport, PacketBytes.copyOf(byteArrayOf(0x23, 0x45))).toByteArray()) val source = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), 27910) val packet = NetworkPacket.fromEnvelope(source, envelope) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt new file mode 100644 index 00000000..00d271f8 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt @@ -0,0 +1,250 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import jake2.qcommon.usercmd_t +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.transport.LocalSide +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.UUID + +/** Deterministic protocol simulations: no sockets, wall-clock waits, Cvars, or Globals. */ +class ReliableChannelTest { + private val empty = bytes() + + @Test + fun `first client packet matches legacy wire layout and channel qport`() { + val client = ReliableChannel(ChannelRole.CLIENT, 0x1234) + assertEquals(ReliableQueueResult.QUEUED, client.enqueueReliable(bytes(0x55))) + val packet = prepare(client, bytes(0x66)) + assertArrayEquals(byteArrayOf(1, 0, 0, -128, 0, 0, 0, 0, 0x34, 0x12, 0x55, 0x66), packet.bytes.toByteArray()) + assertEquals(1, client.outgoingSequence) + client.completeSend(packet, true, 100) + assertEquals(2, client.outgoingSequence) + assertEquals(1, client.reliableSentFlag) + assertEquals(2, client.lastReliableSequence) + assertEquals(100, client.lastSentMillis) + assertEquals(12, client.lastSentSize) + } + + @Test + fun `lost reliable payload retransmits only after the legacy acknowledgement threshold`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + val server = ReliableChannel(ChannelRole.SERVER, 7) + val original = byteArrayOf(11, 12) + client.enqueueReliable(PacketBytes.copyOf(original)) + val lost = send(client, now = 10) + original.fill(99) + assertTrue(lost.containsReliable) + + accept(server, send(client, now = 20), PacketDirection.FROM_CLIENT, 25) + accept(client, send(server, now = 30), PacketDirection.FROM_SERVER, 35) + assertFalse(client.needsReliable, "Ack 2 must not retransmit the reliable sent with sequence 1") + + accept(server, send(client, now = 40), PacketDirection.FROM_CLIENT, 45) + accept(client, send(server, now = 50), PacketDirection.FROM_SERVER, 55) + assertTrue(client.needsReliable) + val retry = send(client, now = 60) + assertTrue(retry.containsReliable) + assertArrayEquals(byteArrayOf(11, 12), body(retry, PacketDirection.FROM_CLIENT)) + accept(server, retry, PacketDirection.FROM_CLIENT, 65) + assertEquals(1, server.reliableReceivedFlag) + assertEquals(ChannelAcceptResult.Rejected(ChannelRejection.OUT_OF_ORDER), + server.accept(envelope(lost, PacketDirection.FROM_CLIENT), 66)) + assertEquals(1, server.reliableReceivedFlag) + accept(client, send(server, now = 70), PacketDirection.FROM_SERVER, 75) + assertTrue(client.canSendReliable) + assertEquals(0, client.unacknowledgedReliableBytes) + } + + @Test + fun `lost acknowledgement is recovered without retransmitting delivered reliable bytes`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + val server = ReliableChannel(ChannelRole.SERVER, 7) + client.enqueueReliable(bytes(11)) + accept(server, send(client), PacketDirection.FROM_CLIENT) + send(server) // Drop the acknowledgement. + val next = send(client) + assertFalse(next.containsReliable) + accept(server, next, PacketDirection.FROM_CLIENT) + accept(client, send(server), PacketDirection.FROM_SERVER) + assertTrue(client.canSendReliable) + assertEquals(1, server.reliableReceivedFlag) + } + + @Test + fun `queued reliable batches wait for acknowledgement and alternate flags`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + val server = ReliableChannel(ChannelRole.SERVER, 7) + client.enqueueReliable(bytes(1)) + accept(server, send(client), PacketDirection.FROM_CLIENT) + client.enqueueReliable(bytes(2)) + assertFalse(send(client).containsReliable) + assertEquals(1, client.pendingReliableBytes) + accept(client, send(server), PacketDirection.FROM_SERVER) + val second = send(client) + assertArrayEquals(byteArrayOf(2), body(second, PacketDirection.FROM_CLIENT)) + assertEquals(0, client.reliableSentFlag) + accept(server, second, PacketDirection.FROM_CLIENT) + assertEquals(0, server.reliableReceivedFlag) + accept(client, send(server), PacketDirection.FROM_SERVER) + assertTrue(client.canSendReliable) + } + + @Test + fun `failed send preserves sequence queues flags and statistics for retry`() { + val channel = ReliableChannel(ChannelRole.SERVER, 0) + channel.enqueueReliable(bytes(1, 2)) + val blocked = prepare(channel, bytes(3)) + channel.completeSend(blocked, false, 100) + assertEquals(1, channel.outgoingSequence) + assertEquals(2, channel.pendingReliableBytes) + assertEquals(0, channel.unacknowledgedReliableBytes) + assertEquals(0, channel.reliableSentFlag) + assertEquals(0, channel.lastSentMillis) + assertEquals(0, channel.lastSentSize) + assertEquals(0, channel.trackedSendCount) + val retry = prepare(channel, bytes(3)) + assertEquals(blocked.bytes, retry.bytes) + channel.completeSend(retry, true, 150) + assertEquals(2, channel.outgoingSequence) + assertEquals(0, channel.pendingReliableBytes) + assertEquals(2, channel.unacknowledgedReliableBytes) + assertEquals(150, channel.lastSentMillis) + } + + @Test + fun `failed retransmission retains its retry eligibility and original bytes`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + val server = ReliableChannel(ChannelRole.SERVER, 7) + client.enqueueReliable(bytes(10)) + send(client) // Lost. + send(client) // Lost. + accept(server, send(client), PacketDirection.FROM_CLIENT) + accept(client, send(server), PacketDirection.FROM_SERVER) + val retry = prepare(client) + assertTrue(retry.containsReliable) + client.completeSend(retry, false, 100) + assertTrue(client.needsReliable) + val secondTry = prepare(client) + assertEquals(retry.bytes, secondTry.bytes) + client.completeSend(secondTry, true, 110) + } + + @Test + fun `duplicate or reordered packets cannot change acknowledgements or receive statistics`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + val server = ReliableChannel(ChannelRole.SERVER, 7) + val older = send(client) + client.enqueueReliable(bytes(1)) + val newer = send(client) + accept(server, newer, PacketDirection.FROM_CLIENT, 100) + assertEquals(1, server.dropped) + for (packet in listOf(older, newer)) { + assertEquals(ChannelAcceptResult.Rejected(ChannelRejection.OUT_OF_ORDER), + server.accept(envelope(packet, PacketDirection.FROM_CLIENT), 200)) + } + assertEquals(2, server.incomingSequence) + assertEquals(1, server.reliableReceivedFlag) + assertEquals(100, server.lastReceivedMillis) + assertEquals(newer.bytes.size, server.lastReceivedSize) + assertEquals(ChannelAcceptResult.Rejected(ChannelRejection.WRONG_DIRECTION), + client.accept(envelope(newer, PacketDirection.FROM_CLIENT), 200)) + } + + @Test + fun `bounded queue rejects overflow without losing already queued bytes`() { + val channel = ReliableChannel(ChannelRole.CLIENT, 7, ChannelLimits(20, 4, 4)) + assertEquals(ReliableQueueResult.QUEUED, channel.enqueueReliable(bytes(1, 2))) + assertEquals(ReliableQueueResult.QUEUED, channel.enqueueReliable(bytes(3, 4))) + assertEquals(ReliableQueueResult.FULL, channel.enqueueReliable(bytes(5))) + assertEquals(4, channel.pendingReliableBytes) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), body(send(channel), PacketDirection.FROM_CLIENT)) + channel.enqueueReliable(bytes(6)) + channel.discardPending() + assertEquals(0, channel.pendingReliableBytes) + assertEquals(4, channel.unacknowledgedReliableBytes) + } + + @Test + fun `reliable bytes take priority and exact-fit unreliable bytes are included`() { + val channel = ReliableChannel(ChannelRole.CLIENT, 7, ChannelLimits(20, 4, 4)) + channel.enqueueReliable(bytes(1, 2, 3, 4)) + val exact = prepare(channel, bytes(5, 6, 7, 8, 9, 10)) + assertEquals(20, exact.bytes.size) + assertFalse(exact.unreliableDropped) + channel.completeSend(exact, false, 0) + val tooLarge = prepare(channel, bytes(5, 6, 7, 8, 9, 10, 11)) + assertEquals(14, tooLarge.bytes.size) + assertTrue(tooLarge.unreliableDropped) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), body(tooLarge, PacketDirection.FROM_CLIENT)) + channel.completeSend(tooLarge, true, 10) + } + + @Test + fun `RTT uses successful completion time and history stays bounded without acknowledgements`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7, ChannelLimits(rttHistorySize = 4)) + val server = ReliableChannel(ChannelRole.SERVER, 7) + accept(server, send(client, now = 100), PacketDirection.FROM_CLIENT, 120) + accept(client, send(server, now = 130), PacketDirection.FROM_SERVER, 160) + assertEquals(60, client.smoothedPingMillis) + accept(server, send(client, now = 200), PacketDirection.FROM_CLIENT, 220) + accept(client, send(server, now = 230), PacketDirection.FROM_SERVER, 300) + assertEquals(70, client.smoothedPingMillis) + repeat(20) { send(client, now = 400L + it) } + assertEquals(4, client.trackedSendCount) + } + + @Test + fun `prepared send must complete once and belongs to its channel`() { + val a = ReliableChannel(ChannelRole.CLIENT, 1) + val b = ReliableChannel(ChannelRole.CLIENT, 2) + val packet = prepare(a) + assertThrows(IllegalStateException::class.java) { prepare(a) } + assertThrows(IllegalStateException::class.java) { a.enqueueReliable(bytes(1)) } + assertThrows(IllegalStateException::class.java) { b.completeSend(packet, true, 0) } + a.completeSend(packet, false, 0) + assertThrows(IllegalStateException::class.java) { a.completeSend(packet, true, 0) } + assertEquals(1, a.outgoingSequence) + assertEquals(1, b.outgoingSequence) + } + + @Test + fun `unreliable encoder receives reserved sequence and failure leaves channel reusable`() { + val client = ReliableChannel(ChannelRole.CLIENT, 7) + assertThrows(IllegalStateException::class.java) { + client.prepareSend { _, _ -> client.enqueueReliable(bytes(1)); empty } + } + assertThrows(IllegalArgumentException::class.java) { + client.prepareSend { _, _ -> throw IllegalArgumentException("encoder failed") } + } + val packet = assertInstanceOf(PrepareSendResult.Prepared::class.java, client.prepareSend { sequence, capacity -> + assertEquals(1, sequence) + val message = MoveMessage(false, 3, usercmd_t(), usercmd_t(), usercmd_t(), sequence) + val buffer = sizebuf_t(capacity) + message.writeTo(buffer) + PacketBytes.copyOf(buffer.data, buffer.cursize) + }) + client.completeSend(packet, true, 10) + val legacy = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), + envelope(packet, PacketDirection.FROM_CLIENT)) + assertTrue((legacy.parseBodyFromClient(1).single() as MoveMessage).valid) + } + + private fun bytes(vararg values: Int) = PacketBytes.copyOf(ByteArray(values.size) { values[it].toByte() }) + private fun prepare(channel: ReliableChannel, payload: PacketBytes = empty) = + assertInstanceOf(PrepareSendResult.Prepared::class.java, channel.prepareSend(payload)) + private fun send(channel: ReliableChannel, payload: PacketBytes = empty, now: Long = 0): PrepareSendResult.Prepared = + prepare(channel, payload).also { channel.completeSend(it, true, now) } + private fun envelope(packet: PrepareSendResult.Prepared, direction: PacketDirection) = + assertInstanceOf(PacketEnvelope.Sequenced::class.java, + assertInstanceOf(PacketDecodeResult.Decoded::class.java, PacketCodec.decode(packet.bytes, direction)).envelope) + private fun accept(channel: ReliableChannel, packet: PrepareSendResult.Prepared, direction: PacketDirection, now: Long = 0) { + assertEquals(ChannelAcceptResult.Accepted, channel.accept(envelope(packet, direction), now)) + } + private fun body(packet: PrepareSendResult.Prepared, direction: PacketDirection): ByteArray = + packet.bytes.toByteArray().copyOfRange(direction.sequencedHeaderSize, packet.bytes.size) +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 5a2d129a..5a7ce58e 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1859,12 +1859,12 @@ Resets server to host a given map, moves all clients to it (via reconnect) unreliable.addAll(client.unreliable) client.unreliable.clear() // send the datagram - client.frames[gameImports.frameNum and Defines.UPDATE_MASK].senttime = realtime - client.netchan.transmit(unreliable) + val sent = client.netchan.transmit(unreliable) + if (sent) client.frames[gameImports.frameNum and Defines.UPDATE_MASK].senttime = realtime // record the size for rate estimation client.message_size[frameNum % Defines.RATE_MESSAGES] = - unreliable.sumOf { it.size } + if (sent) unreliable.sumOf { it.size } else 0 } // endregion From 4aaa14fe3241ea8d4c30d883f114009a84cb57a0 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 00:05:36 +0100 Subject: [PATCH 21/57] Extract instance-owned network transport --- .../main/java/jake2/qcommon/network/NET.java | 78 +++++++++------- .../network/runtime/NetworkTransport.kt | 87 ++++++++++++++++++ .../network/runtime/NetworkTransportTest.kt | 88 +++++++++++++++++++ 3 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 34ad51df..a133d347 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -28,6 +28,7 @@ import jake2.qcommon.exec.Cvar; import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.network.messages.NetworkPacket; +import jake2.qcommon.network.runtime.NetworkTransport; import jake2.qcommon.network.protocol.PacketCodec; import jake2.qcommon.network.protocol.PacketDecodeResult; import jake2.qcommon.network.protocol.PacketDirection; @@ -45,32 +46,32 @@ public final class NET { /** Legacy address bridge; transport endpoints themselves use immutable addresses. */ public static final netadr_t net_local_adr = new netadr_t(); - // Temporary role owners until ClientNetwork and ServerNetwork receive their endpoints. - private static final UdpDatagramEndpoint[] udpEndpoints = new UdpDatagramEndpoint[2]; - private static LoopbackLink loopback = new LoopbackLink(); + // Compatibility composition root until application controllers receive their transports. + private static NetworkTransport[] transports = newTransports(); - public static UdpDatagramEndpoint udpEndpoint(int role) { - return udpEndpoints[role]; + private static NetworkTransport[] newTransports() { + LoopbackLink link = new LoopbackLink(); + return new NetworkTransport[] { + new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()), + new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()) + }; } - private static LoopbackLink.Endpoint localEndpoint(int role) { - return role == Defines.NS_CLIENT ? loopback.getFirst() : loopback.getSecond(); + public static UdpDatagramEndpoint udpEndpoint(int role) { + return transports[role].getUdpEndpoint(); } public static void SendLoopPacket(int role, int length, byte[] data, netadr_t to) { - localEndpoint(role).send(localEndpoint(role ^ 1).getLocalAddress(), PacketBytes.copyOf(data, length)); + transports[role].send(transports[role].getLocalPeer(), PacketBytes.copyOf(data, length)); } /** Receive only local traffic, including final messages after the UDP role has stopped. */ public static NetworkPacket receiveLoopbackPacket(int role) { - return adaptReceived(localEndpoint(role).receive(), role == NS_SERVER); + return adaptReceived(transports[role].receiveLocal(), role == NS_SERVER); } public static NetworkPacket receiveNetworkPacket(int role) { - NetworkPacket local = receiveLoopbackPacket(role); - if (local != null) return local; - UdpDatagramEndpoint endpoint = udpEndpoints[role]; - return endpoint == null ? null : adaptReceived(endpoint.receive(), role == NS_SERVER); + return adaptReceived(transports[role].receive(), role == NS_SERVER); } // Legacy nullable API and trace output; decoding itself has explicit, side-effect-free outcomes. @@ -103,9 +104,9 @@ public static SendResult SendPacket(int role, int length, byte[] data, netadr_t public static SendResult sendPacket(int role, PacketBytes payload, netadr_t to) { if (to.type == NetAddrType.NA_LOOPBACK) { - return localEndpoint(role).send(localEndpoint(role ^ 1).getLocalAddress(), payload); + return transports[role].send(transports[role].getLocalPeer(), payload); } - UdpDatagramEndpoint endpoint = udpEndpoints[role]; + UdpDatagramEndpoint endpoint = udpEndpoint(role); if (endpoint == null) return new SendResult.Failed(new ClosedChannelException()); if (to.type != NetAddrType.NA_BROADCAST && to.type != NetAddrType.NA_IP) { throw new IllegalArgumentException("NET_SendPacket: unsupported address type " + to.type); @@ -113,7 +114,7 @@ public static SendResult sendPacket(int role, PacketBytes payload, netadr_t to) try { NetworkAddress.Udp destination = new NetworkAddress.Udp( IpAddress.fromInetAddress(to.getInetAddress()), to.port); - SendResult result = endpoint.send(destination, payload); + SendResult result = transports[role].send(destination, payload); if (result instanceof SendResult.Failed failure) { Com.Println("NET_SendPacket ERROR: " + failure.getCause() + " to " + to); } @@ -157,38 +158,47 @@ private static boolean configureRole(int role, boolean enabled, String portName, closeRole(role); return true; } - if (udpEndpoints[role] != null && udpEndpoints[role].isOpen()) return true; + if (udpEndpoint(role) != null && udpEndpoint(role).isOpen()) return true; closeRole(role); Cvar cvars = Cvar.getInstance(); String ip = cvars.find("ip", String.class).getTypedValue(); int port = cvars.find(portName, Integer.class).getTypedValue(); - udpEndpoints[role] = openEndpoint(ip, port); - if (udpEndpoints[role] == null && fallback) - udpEndpoints[role] = openEndpoint(ip, Defines.PORT_ANY); - return udpEndpoints[role] != null; + UdpDatagramEndpoint endpoint = openEndpoint(ip, port); + if (endpoint == null && fallback) endpoint = openEndpoint(ip, Defines.PORT_ANY); + try { + transports[role].replaceUdp(endpoint); + } catch (IOException error) { + Com.Println("Closing previous UDP endpoint: " + error.getMessage()); + } + return endpoint != null; } private static void closeRole(int role) { - UdpDatagramEndpoint endpoint = udpEndpoints[role]; - udpEndpoints[role] = null; - if (endpoint != null) { - try { - endpoint.close(); - } catch (IOException error) { - Com.Println("Closing UDP endpoint: " + error.getMessage()); - } + try { + transports[role].replaceUdp(null); + } catch (IOException error) { + Com.Println("Closing UDP endpoint: " + error.getMessage()); } } - /** Whole-session boundary only: do not discard reconnect traffic during map changes. */ + /** Whole-session boundary only: preserve UDP bindings while retiring the local pair. */ public static void clearLoopbacks() { - loopback.close(); - loopback = new LoopbackLink(); + NetworkTransport[] previous = transports; + transports = newTransports(); + for (int role = 0; role < previous.length; role++) { + // UDP ownership remains with the role across a local-session reset. + try { + transports[role].replaceUdp(previous[role].detachUdp()); + previous[role].close(); + } catch (IOException error) { + Com.Println("Closing network transport: " + error.getMessage()); + } + } } /** Discard incoming packets for one role without dropping the other role's final messages. */ public static void clearLoopback(int role) { - localEndpoint(role).clearIncoming(); + transports[role].clearLocalIncoming(); } /** Legacy combined policy for callers that own both socket roles. */ @@ -225,7 +235,7 @@ public static void Shutdown() { /** Sleeps msec or until net socket is ready. */ public static void Sleep(int msec) { - if (udpEndpoints[NS_SERVER] == null + if (udpEndpoint(NS_SERVER) == null || (Globals.dedicated != null && !Globals.dedicated.getTypedValue())) return; // we're not a server, just run full speed diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt new file mode 100644 index 00000000..70b699f7 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt @@ -0,0 +1,87 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.network.transport.* +import java.io.IOException +import java.nio.channels.ClosedChannelException + +/** + * Transport resources belonging to one client or server, confined to its game-loop thread. + * The composition root supplies one end of a private local link. Bind policy belongs to callers. + */ +class NetworkTransport( + private val local: LoopbackLink.Endpoint, + val localPeer: NetworkAddress.Local, +) : AutoCloseable { + var udpEndpoint: UdpDatagramEndpoint? = null + private set + private var localFirst = true + private var closed = false + + init { + require(local.localAddress.linkId == localPeer.linkId && local.localAddress != localPeer) + } + + /** Transfers ownership; replacing UDP leaves local traffic and its lifetime intact. */ + @Throws(IOException::class) + fun replaceUdp(endpoint: UdpDatagramEndpoint?) { + check(!closed) { "Transport is closed" } + if (udpEndpoint === endpoint) return + val previous = udpEndpoint + udpEndpoint = endpoint + previous?.close() + } + + /** Transfer the binding to a new session owner without closing it. */ + fun detachUdp(): UdpDatagramEndpoint? = udpEndpoint.also { udpEndpoint = null } + + fun send(to: NetworkAddress, payload: PacketBytes): SendResult { + if (closed) return SendResult.Failed(ClosedChannelException()) + return when (to) { + is NetworkAddress.Local -> local.send(to, payload) + is NetworkAddress.Udp -> udpEndpoint?.send(to, payload) + ?: SendResult.Failed(ClosedChannelException()) + } + } + + fun receiveLocal(): ReceiveResult = local.receive() + + /** Alternate first choice so a busy local peer cannot starve remote peers. */ + fun receive(): ReceiveResult { + if (closed) return ReceiveResult.Failed(ClosedChannelException()) + val first = if (localFirst) local else udpEndpoint + val second = if (localFirst) udpEndpoint else local + localFirst = !localFirst + val result = first?.receive() ?: ReceiveResult.Empty + return if (result == ReceiveResult.Empty) second?.receive() ?: ReceiveResult.Empty else result + } + + /** Every consumed datagram, including a dropped one, counts against the frame budget. */ + fun poll(budget: Int, consume: (ReceiveResult) -> Unit): Int { + require(budget >= 0) + var count = 0 + while (count < budget) { + val result = receive() + if (result == ReceiveResult.Empty) break + count++ + consume(result) + if (result is ReceiveResult.Failed) break + } + return count + } + + fun clearLocalIncoming() = local.clearIncoming() + + @Throws(IOException::class) + override fun close() { + if (closed) return + closed = true + val endpoint = udpEndpoint + udpEndpoint = null + try { + endpoint?.close() + } finally { + // Closing our receiver must not discard final messages queued for our peer. + local.close() + } + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt new file mode 100644 index 00000000..5d62c1e5 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt @@ -0,0 +1,88 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.time.Duration + +class NetworkTransportTest { + private fun pair(): Pair { + val link = LoopbackLink() + return NetworkTransport(link.first, link.second.localAddress) to + NetworkTransport(link.second, link.first.localAddress) + } + + private val bytes = PacketBytes.copyOf(byteArrayOf(1, 2, 3)) + + @Test + fun `continuous local traffic does not starve UDP`() { + val (a, b) = pair() + a.use { b.use { + val udp = UdpDatagramEndpoint.bind(InetSocketAddress("127.0.0.1", 0)) + b.replaceUdp(udp) + UdpDatagramEndpoint.bind(InetSocketAddress("127.0.0.1", 0)).use { sender -> + assertEquals(SendResult.Sent, sender.send(udp.localAddress, bytes)) + assertTimeoutPreemptively(Duration.ofSeconds(2)) { + var receivedUdp = false + while (!receivedUdp) { + a.send(a.localPeer, bytes) + val result = b.receive() as ReceiveResult.Received + receivedUdp = result.datagram.source is NetworkAddress.Udp + } + } + // UDP selection did not consume the queued local message. + assertTrue((b.receive() as ReceiveResult.Received).datagram.source is NetworkAddress.Local) + } + } } + } + + @Test + fun `independent pairs retain final peer traffic after sender closes`() { + val (a, b) = pair() + val (c, d) = pair() + a.use { b.use { c.use { d.use { + assertEquals(SendResult.Sent, a.send(a.localPeer, bytes)) + assertEquals(SendResult.Sent, c.send(c.localPeer, bytes)) + a.close() + assertEquals(bytes, (b.receive() as ReceiveResult.Received).datagram.payload) + assertEquals(bytes, (d.receive() as ReceiveResult.Received).datagram.payload) + assertEquals(SendResult.Sent, d.send(d.localPeer, bytes)) + assertTrue(c.receive() is ReceiveResult.Received) + assertTrue(a.send(a.localPeer, bytes) is SendResult.Failed) + } } } } + } + + @Test + fun `poll budget bounds work even when callback keeps producing traffic`() { + val (a, b) = pair() + a.use { b.use { + a.send(a.localPeer, bytes) + assertEquals(3, b.poll(3) { + assertTrue(it is ReceiveResult.Received) + a.send(a.localPeer, bytes) + }) + assertTrue(b.receive() is ReceiveResult.Received) + assertEquals(0, b.poll(0) { fail("zero budget") }) + assertThrows(IllegalArgumentException::class.java) { b.poll(-1) {} } + } } + } + + @Test + fun `binding transfers across local session reset and closes with new owner`() { + val (a, b) = pair() + val (c, d) = pair() + val udp = UdpDatagramEndpoint.bind(InetSocketAddress(0)) + a.use { b.use { c.use { d.use { + a.replaceUdp(udp) + c.replaceUdp(a.detachUdp()) + a.close() + assertTrue(udp.isOpen) + assertSame(udp, c.udpEndpoint) + c.replaceUdp(null) + assertFalse(udp.isOpen) + assertEquals(SendResult.Sent, c.send(c.localPeer, bytes)) + assertTrue(d.receive() is ReceiveResult.Received) + } } } } + } +} From aff53525a402d7cc7bf553ebbcb126338369ddb8 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 00:11:33 +0100 Subject: [PATCH 22/57] Allow connection adapters to borrow owned transports --- .../java/jake2/qcommon/network/netchan_t.java | 26 ++++++- .../network/runtime/OwnedChannelTest.kt | 76 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java index a33a9484..432e55b6 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java @@ -30,6 +30,9 @@ import jake2.qcommon.network.protocol.*; import jake2.qcommon.network.transport.PacketBytes; import jake2.qcommon.network.transport.SendResult; +import jake2.qcommon.network.transport.NetworkAddress; +import jake2.qcommon.network.transport.IpAddress; +import jake2.qcommon.network.runtime.NetworkTransport; import jake2.qcommon.sizebuf_t; import java.util.ArrayList; @@ -62,6 +65,7 @@ public class netchan_t { public final Collection reliablePending = new ArrayList<>(); private final LongSupplier clockMillis; + private final NetworkTransport transport; private ReliableChannel channel = new ReliableChannel(ChannelRole.CLIENT, 0); public netchan_t() { @@ -69,6 +73,12 @@ public netchan_t() { } public netchan_t(LongSupplier clockMillis) { + this(null, clockMillis); + } + + /** The channel borrows its owner's transport; clearing a connection never closes it. */ + public netchan_t(NetworkTransport transport, LongSupplier clockMillis) { + this.transport = transport; this.clockMillis = clockMillis; } @@ -120,7 +130,7 @@ public boolean transmit(Collection unreliable) { } PrepareSendResult.Prepared transmission = (PrepareSendResult.Prepared) result; try { - sent = NET.sendPacket(sock, transmission.getBytes(), remote_address) instanceof SendResult.Sent; + sent = send(transmission.getBytes()) instanceof SendResult.Sent; } finally { channel.completeSend(transmission, sent, clockMillis.getAsLong()); } @@ -144,6 +154,20 @@ public boolean transmit(Collection unreliable) { } } + private SendResult send(PacketBytes bytes) { + if (transport == null) return NET.sendPacket(sock, bytes, remote_address); + if (remote_address.type == NetAddrType.NA_LOOPBACK) + return transport.send(transport.getLocalPeer(), bytes); + if (remote_address.type != NetAddrType.NA_IP && remote_address.type != NetAddrType.NA_BROADCAST) + throw new IllegalArgumentException("Unsupported address type " + remote_address.type); + try { + return transport.send(new NetworkAddress.Udp( + IpAddress.fromInetAddress(remote_address.getInetAddress()), remote_address.port), bytes); + } catch (java.io.IOException error) { + return new SendResult.Failed(error); + } + } + private static PacketBytes encodeBatch(Collection messages, int capacity) { if (messages == null || messages.isEmpty()) return PacketBytes.copyOf(new byte[0]); if (messages.stream().mapToLong(NetworkMessage::getSize).sum() > capacity) return null; diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt new file mode 100644 index 00000000..ac11c2b3 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -0,0 +1,76 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.messages.client.StringCmdMessage +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class OwnedChannelTest { + private class Session : AutoCloseable { + private val link = LoopbackLink() + val clientTransport = NetworkTransport(link.first, link.second.localAddress) + val serverTransport = NetworkTransport(link.second, link.first.localAddress) + val client = netchan_t(clientTransport) { 100L } + val server = netchan_t(serverTransport) { 100L } + + init { + client.setup(Defines.NS_CLIENT, netadr_t(), 42) + server.setup(Defines.NS_SERVER, netadr_t(), 42) + } + + fun receive(transport: NetworkTransport, direction: PacketDirection): NetworkPacket { + val datagram = (transport.receive() as ReceiveResult.Received).datagram + val decoded = PacketCodec.decode(datagram.payload, direction) as PacketDecodeResult.Decoded + return NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) + } + + override fun close() { + clientTransport.close() + serverTransport.close() + } + } + + @Test + fun `two channel pairs deliver and acknowledge independently without global transport`() { + Session().use { first -> Session().use { second -> + first.client.reliablePending.add(StringCmdMessage("first")) + second.client.reliablePending.add(StringCmdMessage("second")) + assertTrue(first.client.transmit(null)) + assertTrue(second.client.transmit(null)) + val firstPacket = first.receive(first.serverTransport, PacketDirection.FROM_CLIENT) + val secondPacket = second.receive(second.serverTransport, PacketDirection.FROM_CLIENT) + assertTrue(first.server.accept(firstPacket)) + assertTrue(second.server.accept(secondPacket)) + assertEquals("first", (firstPacket.parseBodyFromClient(1).single() as StringCmdMessage).command) + assertEquals("second", (secondPacket.parseBodyFromClient(1).single() as StringCmdMessage).command) + assertTrue(first.server.transmit(null)) + assertTrue(first.client.accept(first.receive(first.clientTransport, PacketDirection.FROM_SERVER))) + assertTrue(first.client.canReliable()) + assertFalse(second.client.canReliable()) + first.close() + assertTrue(second.server.transmit(null)) + assertTrue(second.client.accept(second.receive(second.clientTransport, PacketDirection.FROM_SERVER))) + assertTrue(second.client.canReliable()) + } } + } + + @Test + fun `clearing a channel retains its borrowed transport and failed send retains staging`() { + Session().use { session -> + session.client.clear() + session.client.setup(Defines.NS_CLIENT, netadr_t(), 99) + assertTrue(session.client.transmit(null)) + assertEquals(99, session.receive(session.serverTransport, PacketDirection.FROM_CLIENT).qport) + session.clientTransport.close() + session.client.reliablePending.add(StringCmdMessage("retry")) + assertFalse(session.client.transmit(null)) + assertEquals(2, session.client.outgoing_sequence) + assertEquals(1, session.client.reliablePending.size) + } + } +} From 6fb6f2cbbe5b28d6cf33753370efb18b222b7230 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 00:16:35 +0100 Subject: [PATCH 23/57] Inject server networking and owned transport lifecycle --- .../java/jake2/server/ClientNetworkInfo.java | 7 +- .../main/kotlin/jake2/server/ServerHost.kt | 109 ++++++++---------- .../main/kotlin/jake2/server/ServerNetwork.kt | 109 ++++++++++++++++++ .../kotlin/jake2/server/ServerNetworkTest.kt | 61 ++++++++++ 4 files changed, 222 insertions(+), 64 deletions(-) create mode 100644 server/src/main/kotlin/jake2/server/ServerNetwork.kt create mode 100644 server/src/test/kotlin/jake2/server/ServerNetworkTest.kt diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index d4eff93b..c8567ea8 100644 --- a/server/src/main/java/jake2/server/ClientNetworkInfo.java +++ b/server/src/main/java/jake2/server/ClientNetworkInfo.java @@ -39,6 +39,11 @@ of the License, or (at your option) any later version. public class ClientNetworkInfo { public ClientNetworkInfo() { + this(new netchan_t()); + } + + public ClientNetworkInfo(netchan_t channel) { + this.netchan = java.util.Objects.requireNonNull(channel); for (int n = 0; n < Defines.UPDATE_BACKUP; n++) { frames[n] = new client_frame_t(); } @@ -90,7 +95,7 @@ public ClientNetworkInfo() { int challenge; // challenge of this user, randomly generated - netchan_t netchan = new netchan_t(); + netchan_t netchan; /** Queue text only when it meets this connection's requested message level. */ void queuePrint(int level, String text) { diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 5a7ce58e..21ed916e 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -24,9 +24,7 @@ package jake2.server import jake2.qcommon.* import jake2.qcommon.exec.* import jake2.qcommon.exec.CvarTypes.ChoiceType -import jake2.qcommon.network.NET import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.client.* @@ -40,6 +38,7 @@ import jake2.server.save.ServerSaveJsonStore /** Owns client connections, Game routing, protocol and commands for one server Host. */ class ServerHost @JvmOverloads constructor( + private val network: ServerNetwork = LegacyServerNetwork, private val startupRejection: () -> String? = { null }, ) : AutoCloseable { // region Host state and command dispatch @@ -297,8 +296,10 @@ class ServerHost @JvmOverloads constructor( /** Release this manager's console hooks after ending its last session. */ override fun close() { - commands.use { - stop("Server stopped\n", false) + network.use { + commands.use { + stop("Server stopped\n", false) + } } } @@ -323,9 +324,7 @@ class ServerHost @JvmOverloads constructor( challengeOrder = 0 // Ordinary queued commands may start the next session (killserver; gamemap ...). Cbuf.ClearDeferred() - NET.configureServer(false) - // Keep outgoing final messages available to the local client. - NET.clearLoopback(Defines.NS_SERVER) + network.stop() } } @@ -373,7 +372,7 @@ class ServerHost @JvmOverloads constructor( // Clear all clients clients = MutableList(max) { - ClientNetworkInfo().apply { lastcmd = usercmd_t() } + ClientNetworkInfo(network.newChannel()).apply { lastcmd = usercmd_t() } } } @@ -392,7 +391,7 @@ class ServerHost @JvmOverloads constructor( Cvar.getInstance().find("maxclients").forceSet(savedMaxClients) savedMaxClients > 1 } - check(NET.configureServer(multiplayer)) { "Could not open the server UDP port" } + check(network.configure(multiplayer)) { "Could not open the server UDP port" } if (savedMaxClients == null || clients.size != savedMaxClients) resetClients() } @@ -825,7 +824,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (delay > 0) { // Bound the dedicated sleep while waiting for the next scheduled tick. delay = delay.coerceAtMost(100) - NET.Sleep(delay.toInt()) + network.sleep(delay.toInt()) return } @@ -1020,8 +1019,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) val version = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (version != Defines.PROTOCOL_VERSION) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, adr, ConnectionlessCommand.print, + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nServer is version " + Globals.VERSION + "\n" ) Com.DPrintf(" rejected connect from version $version\n") @@ -1043,9 +1041,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (adr.CompareBaseAdr(challenges[j].adr)) { if (challenge == challenges[j].challenge) break // good - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nBad challenge.\n" ) @@ -1054,9 +1050,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) j++ } if (j == Defines.MAX_CHALLENGES) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nNo challenge for address.\n" ) @@ -1094,7 +1088,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) i++ } if (index == -1) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n") + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nServer is full.\n") Com.DPrintf("Rejected a connection.\n") return } @@ -1123,16 +1117,12 @@ Resets server to host a given map, moves all clients to it (via reconnect) // userinfo if (!(gameImports.gameExports.ClientConnect(ent, userinfo))) { if (Info.Info_ValueForKey(userinfo, "rejmsg") != null) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, + network.sendConnectionless(adr, ConnectionlessCommand.print, "\n" + Info.Info_ValueForKey(userinfo, "rejmsg") + "\nConnection refused.\n" ) } else { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nConnection refused.\n" ) @@ -1145,7 +1135,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.userinfo = userinfo updateClientUserinfo(client) - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, "") + network.sendConnectionless(adr, ConnectionlessCommand.client_connect, "") client.netchan.setup(Defines.NS_SERVER, adr, qport) @@ -1174,24 +1164,20 @@ Resets server to host a given map, moves all clients to it (via reconnect) when (cmd) { ConnectionlessCommand.ping -> // SVC_Ping, Just responds with an acknowledgement. - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, "") + network.sendConnectionless(from, ConnectionlessCommand.ack, "") ConnectionlessCommand.ack -> //SVC_Ack Com.Printf("Ping acknowledge from $from\n") ConnectionlessCommand.status -> // SVC_Status, Responds with all the info that qplug or qspy can see - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, + network.sendConnectionless(from, ConnectionlessCommand.print, "\n" + buildStatusResponse() ) ConnectionlessCommand.info -> { val info = buildInfoResponse(args) - if (info != null) Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, + if (info != null) network.sendConnectionless(from, ConnectionlessCommand.info, "\n" + info ) @@ -1199,9 +1185,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) ConnectionlessCommand.getchallenge -> { val challenge = getChallenge(from) - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, + network.sendConnectionless(from, ConnectionlessCommand.challenge, " $challenge" ) @@ -1246,7 +1230,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) */ fun readPackets() { while (true) { - val networkPacket = NET.receiveNetworkPacket(Defines.NS_SERVER) + val networkPacket = network.receive() if (networkPacket == null) break @@ -2014,35 +1998,34 @@ Resets server to host a given map, moves all clients to it (via reconnect) return args.size >= 2 && args[1] == rconPassword } - /** - * A client issued an rcon command. Shift down the remaining args Redirect - * all printfs from the server to the client. - */ - private fun executeRemoteCommand(args: List, from: netadr_t?) { - val rconIsValid: Boolean = isRconAuthorized(args) + } - if (rconIsValid) { - Com.Printf("Rcon from $from:\n$args\n") - } else { - Com.Printf("Bad rcon from $from:\n$args\n") - } + /** + * A client issued an rcon command. Shift down the remaining args Redirect + * all printfs from the server to the client. + */ + private fun executeRemoteCommand(args: List, from: netadr_t) { + val rconIsValid: Boolean = isRconAuthorized(args) - Com.BeginRedirect { buffer -> - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, - ConnectionlessCommand.print, - "\n" + buffer.toString() - ) - } - if (rconIsValid) { - Cmd.ExecuteString(Cmd.getArguments(args, 2)) - } else { - // redirected to client - Com.Printf("Bad rcon_password.\n") - } + if (rconIsValid) { + Com.Printf("Rcon from $from:\n$args\n") + } else { + Com.Printf("Bad rcon from $from:\n$args\n") + } - Com.EndRedirect() + Com.BeginRedirect { buffer -> + network.sendConnectionless(from, + ConnectionlessCommand.print, + "\n" + buffer.toString() + ) + } + if (rconIsValid) { + Cmd.ExecuteString(Cmd.getArguments(args, 2)) + } else { + // redirected to client + Com.Printf("Bad rcon_password.\n") } + + Com.EndRedirect() } } diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt new file mode 100644 index 00000000..1ce7f869 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -0,0 +1,109 @@ +package jake2.server + +import jake2.qcommon.Com +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.* +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.* +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ +interface ServerNetwork : AutoCloseable { + fun configure(multiplayer: Boolean): Boolean + fun newChannel(): netchan_t + fun receive(): NetworkPacket? + fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) + fun stop() + fun sleep(millis: Int) + override fun close() = stop() +} + +/** Compatibility composition until application roots supply their own local link. */ +internal object LegacyServerNetwork : ServerNetwork { + override fun configure(multiplayer: Boolean) = NET.configureServer(multiplayer) + override fun newChannel() = netchan_t() + override fun receive() = NET.receiveNetworkPacket(Defines.NS_SERVER) + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, to, command, payload) + override fun stop() { + NET.configureServer(false) + NET.clearLoopback(Defines.NS_SERVER) + } + override fun sleep(millis: Int) = NET.Sleep(millis) +} + +/** Owns the supplied transport; connections borrow it and share the server UDP binding. */ +class TransportServerNetwork(private val transport: NetworkTransport) : ServerNetwork { + private var closed = false + override fun newChannel() = netchan_t(transport) { System.nanoTime() / 1_000_000L } + + override fun configure(multiplayer: Boolean): Boolean { + check(!closed) { "Server network is closed" } + if (!multiplayer) { + transport.replaceUdp(null) + return true + } + if (transport.udpEndpoint?.isOpen == true) return true + val cvars = Cvar.getInstance() + val ip = cvars.find("ip", String::class.java).typedValue + val port = cvars.find("port", Int::class.javaObjectType).typedValue.let { if (it == Defines.PORT_ANY) 0 else it } + return try { + transport.replaceUdp(UdpDatagramEndpoint.bind( + if (ip.isEmpty() || ip == "localhost") InetSocketAddress(port) else InetSocketAddress(java.net.InetAddress.getByName(ip), port))) + true + } catch (error: java.io.IOException) { + Com.Println("Server UDP bind error: $error") + false + } + } + + override fun receive(): NetworkPacket? { + val received = transport.receive() + if (received is ReceiveResult.Failed) Com.Println("Server receive error: ${received.cause}") + if (received !is ReceiveResult.Received) return null + val datagram = received.datagram + val decoded = PacketCodec.decode(datagram.payload, PacketDirection.FROM_CLIENT) + return if (decoded is PacketDecodeResult.Decoded) NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) + else null + } + + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { + val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) + require(text.size <= Defines.MAX_MSGLEN - 4) + val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() + val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else + NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) + val result = transport.send(destination, PacketBytes.copyOf(bytes)) + if (result is SendResult.Failed) Com.Println("Server send error: ${result.cause}") + } + + override fun stop() { + if (closed) return + try { + transport.replaceUdp(null) + } finally { + transport.clearLocalIncoming() + } + } + + override fun sleep(millis: Int) { + if (transport.udpEndpoint == null) return + try { + Thread.sleep(millis.toLong()) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + override fun close() { + if (closed) return + closed = true + transport.close() + } +} diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt new file mode 100644 index 00000000..1ff922e2 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -0,0 +1,61 @@ +package jake2.server + +import jake2.qcommon.MainCommon +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.transport.* +import jake2.qcommon.network.runtime.NetworkTransport +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ServerNetworkTest { + private class Peer : AutoCloseable { + private val link = LoopbackLink() + val client = NetworkTransport(link.first, link.second.localAddress) + val server = NetworkTransport(link.second, link.first.localAddress) + val network = TransportServerNetwork(server) + val host = ServerHost(network) + fun ping() { + client.send(client.localPeer, PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray())) + host.readPackets() + } + fun reply(): String { + val received = client.receive() as ReceiveResult.Received + return received.datagram.payload.toByteArray().drop(4).toByteArray().toString(Charsets.ISO_8859_1) + } + override fun close() { + host.close() + client.close() + } + } + + @Test + fun `hosts reply on their injected links and closing one leaves the other running`() { + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + try { + Peer().use { first -> Peer().use { second -> + first.ping() + second.ping() + assertEquals("ack", first.reply()) + assertEquals("ack", second.reply()) + Cvar.getInstance().find("port", Int::class.javaObjectType).forceSet(0) + assertTrue(first.network.configure(true)) + val binding = first.server.udpEndpoint!! + assertTrue(binding.isOpen) + first.host.stop("stopped", false) + assertFalse(binding.isOpen) + first.ping() + assertEquals("ack", first.reply()) + first.close() + second.ping() + assertEquals("ack", second.reply()) + assertTrue(second.server.send(second.server.localPeer, PacketBytes.copyOf(byteArrayOf(1))) is SendResult.Sent) + assertTrue(first.server.send(first.server.localPeer, PacketBytes.copyOf(byteArrayOf(1))) is SendResult.Failed) + } } + } finally { + Cvar.getInstance().clear() + } + } +} From a51d8c03dc04279d4de00e9e435470ba68888a5f Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 10:23:48 +0100 Subject: [PATCH 24/57] Own client networking and wire private fullgame transports --- .../src/main/java/jake2/fullgame/Jake2.java | 2 +- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 39 +++++++- .../jake2/server/CakeLocalSessionGlTest.kt | 24 ++--- .../src/main/kotlin/org/demoth/cake/Cake.kt | 27 +++-- .../org/demoth/cake/hosting/CakeHost.kt | 4 + .../org/demoth/cake/network/ClientNetwork.kt | 99 +++++++++++++++++++ .../demoth/cake/network/ClientNetworkTest.kt | 50 ++++++++++ 7 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt diff --git a/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java b/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java index 866d5b5d..eb21a6f9 100644 --- a/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java +++ b/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java @@ -31,6 +31,6 @@ of the License, or (at your option) any later version. /** Desktop composition root for Cake with the embedded server distribution. */ public final class Jake2 { public static void main(String[] args) { - CakeDesktopLauncher.launch(args, new EmbeddedCakeHost()); + CakeDesktopLauncher.launch(args, EmbeddedCakeHost.owned()); } } diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index f5aac080..a1e81f30 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -7,6 +7,11 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.CvarTypes import jake2.qcommon.exec.find import jake2.qcommon.longjmpException +import jake2.qcommon.network.transport.LoopbackLink +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.server.TransportServerNetwork +import org.demoth.cake.network.TransportClientNetwork +import org.demoth.cake.network.LegacyClientNetwork import jake2.qcommon.network.NET import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot @@ -28,7 +33,30 @@ import java.util.UUID /** Application-owned embedded Host; games may stop while commands and profile remain active. */ class EmbeddedCakeHost( private val profilesRoot: Path = Path.of(System.getProperty("user.home"), ".cake"), + private val localLink: LoopbackLink? = null, ) : CakeHost { + companion object { + @JvmStatic fun owned() = EmbeddedCakeHost(localLink = LoopbackLink()) + } + private val clientTransport = localLink?.let { + NetworkTransport(it.first, it.second.localAddress) + } + private val serverTransport = localLink?.let { + NetworkTransport(it.second, it.first.localAddress) + } + override val clientNetwork = clientTransport?.let { TransportClientNetwork(it) } + ?: LegacyClientNetwork + + internal fun udpEndpoint(role: Int) = if (localLink == null) NET.udpEndpoint(role) + else if (role == Defines.NS_CLIENT) clientTransport?.udpEndpoint else serverTransport?.udpEndpoint + + private fun clearLocalTraffic() { + if (localLink == null) NET.clearLoopbacks() else { + localLink.first.clearIncoming() + localLink.second.clearIncoming() + } + } + internal var server: ServerHost? = null private set private var starting = false @@ -45,13 +73,15 @@ class EmbeddedCakeHost( check(server == null) { "Host is already initialized" } this.startupRejection = startupRejection previousWriteRoot = EngineWriteRoot.path() - server = ServerHost { + val rejection = { when { disposed -> "Host has been disposed" activeProfile == null -> "Select a game profile before hosting." else -> this.startupRejection() } } + server = if (serverTransport == null) ServerHost(startupRejection = rejection) + else ServerHost(TransportServerNetwork(serverTransport), rejection) } override fun activateProfile(profile: CakeGameProfile?) { @@ -121,7 +151,7 @@ class EmbeddedCakeHost( return HostStartResult.Rejected(result.reason) } } - if (request.mode != HostMode.SINGLEPLAYER && NET.udpEndpoint(Defines.NS_SERVER) == null) { + if (request.mode != HostMode.SINGLEPLAYER && udpEndpoint(Defines.NS_SERVER) == null) { stop("Server port unavailable") return HostStartResult.Rejected("Could not open the server UDP port") } @@ -170,7 +200,7 @@ class EmbeddedCakeHost( server?.stop("$reason\n", false) } finally { // The adapter also owns disposal of the local client's queued traffic. - if (server != null) NET.clearLoopbacks() + if (server != null) clearLocalTraffic() } } @@ -184,7 +214,8 @@ class EmbeddedCakeHost( try { manager?.close() } finally { - if (manager != null) NET.clearLoopbacks() + if (manager != null) clearLocalTraffic() + if (localLink != null) clientNetwork.close() previousWriteRoot?.let(EngineWriteRoot::setRoot) previousWriteRoot = null activeProfile = null diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index e4eaf411..0e6b48d7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -36,7 +36,7 @@ class CakeLocalSessionGlTest { val basedir = requireNotNull(System.getenv("CAKE_TEST_BASEDIR")) val previousHome = System.getProperty("user.home") System.setProperty("user.home", temp.toString()) - val host = EmbeddedCakeHost(temp.resolve(".cake")) + val host = EmbeddedCakeHost(temp.resolve(".cake"), jake2.qcommon.network.transport.LoopbackLink()) var failure: Throwable? = null val peers = mutableListOf() try { @@ -84,7 +84,7 @@ class CakeLocalSessionGlTest { ticks++ when (step) { 0 -> if (state(cake) == "ACTIVE") { - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) origin = playerOrigin(host) next("+forward") } @@ -97,7 +97,7 @@ class CakeLocalSessionGlTest { } 2 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2") { assertEquals(HostState.RUNNING, host.state) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) next("map base1") } 3 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base1") { @@ -115,13 +115,13 @@ class CakeLocalSessionGlTest { 7 -> { assertEquals(HostState.STOPPED, host.state) assertEquals("DISCONNECTED", state(cake)) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) Cvar.getInstance().find("port").forceSet(0) next("set coop 1; set deathmatch 0; set maxclients 4; map base1") } 8 -> if (state(cake) == "ACTIVE") { - serverEndpoint = NET.udpEndpoint(Defines.NS_SERVER)!! - assertNull(NET.udpEndpoint(Defines.NS_CLIENT)) + serverEndpoint = host.udpEndpoint(Defines.NS_SERVER)!! + assertNull(host.udpEndpoint(Defines.NS_CLIENT)) clientPeer = launchPeer("client", basedir, clientReady, serverEndpoint!!.localAddress.port) peers.add(clientPeer) next() @@ -131,14 +131,14 @@ class CakeLocalSessionGlTest { if (Files.exists(clientReady) && spawnedClients(host) == 2) next("gamemap base2") } 10 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2" && spawnedClients(host) == 2) { - assertSame(serverEndpoint, NET.udpEndpoint(Defines.NS_SERVER)) + assertSame(serverEndpoint, host.udpEndpoint(Defines.NS_SERVER)) clientPeer.destroy() next("disconnect; set coop 0; set deathmatch 1; set maxclients 8; map base1") } 11 -> if (state(cake) == "ACTIVE") { Files.deleteIfExists(clientReady) clientPeer = launchPeer("client", basedir, clientReady, - NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port) + host.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port) peers.add(clientPeer) next() } @@ -157,19 +157,19 @@ class CakeLocalSessionGlTest { } 14 -> if (state(cake) == "ACTIVE") { assertEquals(HostState.STOPPED, host.state) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) - assertNotNull(NET.udpEndpoint(Defines.NS_CLIENT)) + assertNull(host.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(host.udpEndpoint(Defines.NS_CLIENT)) next("disconnect") } 15 -> { assertEquals("DISCONNECTED", state(cake)) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) next("reconnect") } 16 -> if (state(cake) == "ACTIVE") next("disconnect") 17 -> { assertEquals("DISCONNECTED", state(cake)) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) Gdx.app.exit() } } 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 85ab2e30..0fe4ba33 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -22,7 +22,7 @@ import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cmd.getArguments import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET +import org.demoth.cake.network.ClientNetwork import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand @@ -32,7 +32,6 @@ import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.messages.client.UserInfoMessage import jake2.qcommon.network.messages.server.* import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.netchan_t import jake2.qcommon.vfs.VfsDebugCommands import ktx.app.KtxApplicationAdapter import ktx.app.KtxInputAdapter @@ -78,6 +77,7 @@ class Cake( startupContext: CakeStartupContext? = null, private val videoPlatform: CakeVideoPlatform? = null, val host: CakeHost = UnavailableCakeHost(), + private val network: ClientNetwork = host.clientNetwork, ) : KtxApplicationAdapter, KtxInputAdapter { companion object { private const val CONNECT_RETRY_TIMEOUT_SECONDS = 1f @@ -135,7 +135,7 @@ class Cake( private var localConnection = false private var challenge = 0 private var reconnectTimeout = 0f // fire first connect attempt immediately - private val netchan = netchan_t() + private val netchan = network.newChannel() private val downloads = CakeDownloadSession(sendReliable = { command -> netchan.reliablePending.add(StringCmdMessage(command)) @@ -378,11 +378,11 @@ class Cake( } } - if (to.type != NetAddrType.NA_LOOPBACK && !NET.configureClient(true)) { + if (to.type != NetAddrType.NA_LOOPBACK && !network.configure(true)) { Com.Warn("Could not open the client UDP socket\n") return@AddCommand } - Netchan.sendConnectionlessPacket(NS_CLIENT, to, ConnectionlessCommand.rcon, message) + network.sendConnectionless(to, ConnectionlessCommand.rcon, message) } Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { disconnect() } @@ -496,11 +496,11 @@ class Cake( } private fun beginConnection(target: netadr_t, local: Boolean) { - if (!local && !NET.configureClient(true)) { + if (!local && !network.configure(true)) { Com.Warn("Could not open the client UDP socket\n") return } - if (local) NET.configureClient(false) + if (local) network.configure(false) Com.Printf("Connecting to ${if (local) "local server" else target}...\n") connectionTarget = target localConnection = local @@ -515,8 +515,7 @@ class Cake( resetClientConnection() host.stop("Disconnected") hostClock.reset() - NET.configureClient(false) - NET.clearLoopbacks() + network.stop() } /** Reset client state without closing the hosted server or discarding its reconnect traffic. */ @@ -945,7 +944,7 @@ class Cake( try { disconnect() } finally { - host.dispose() + try { host.dispose() } finally { network.close() } } profilePersistence.saveConfig() menuStages.dispose() @@ -1076,7 +1075,7 @@ class Cake( // The server already trusts in-process loopback; use its normal connect path. SendConnectPacket() } else { - Netchan.sendConnectionlessPacket(NS_CLIENT, adr, ConnectionlessCommand.getchallenge, "\n") + network.sendConnectionless(adr, ConnectionlessCommand.getchallenge, "\n") } reconnectTimeout = CONNECT_RETRY_TIMEOUT_SECONDS } else { @@ -1093,9 +1092,7 @@ class Cake( val port = Cvar.getInstance().find("qport").typedValue Globals.userinfo_modified = false - Netchan.sendConnectionlessPacket( - NS_CLIENT, - adr, + network.sendConnectionless(adr, ConnectionlessCommand.connect, " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" ) @@ -1103,7 +1100,7 @@ class Cake( private fun CL_ReadPackets() { while (true) { - val networkPacket = NET.receiveNetworkPacket(NS_CLIENT) + val networkPacket = network.receive() if (networkPacket == null) break diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 03f14836..f5100fcd 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -1,9 +1,13 @@ package org.demoth.cake.hosting import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.network.ClientNetwork +import org.demoth.cake.network.LegacyClientNetwork /** Application-thread boundary between Cake and an optional embedded server. */ interface CakeHost { + val clientNetwork: ClientNetwork + get() = LegacyClientNetwork val availability: HostAvailability val state: HostState diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt new file mode 100644 index 00000000..7c328d99 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -0,0 +1,99 @@ +package org.demoth.cake.network + +import jake2.qcommon.Com +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.* +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.* +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Client transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ +interface ClientNetwork : AutoCloseable { + fun configure(multiplayer: Boolean): Boolean + fun newChannel(): netchan_t + fun receive(): NetworkPacket? + fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) + fun stop() + override fun close() = stop() +} + +/** Compatibility composition until application roots supply their own local link. */ +object LegacyClientNetwork : ClientNetwork { + override fun configure(multiplayer: Boolean) = NET.configureClient(multiplayer) + override fun newChannel() = netchan_t() + override fun receive() = NET.receiveNetworkPacket(Defines.NS_CLIENT) + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, to, command, payload) + override fun stop() { + NET.configureClient(false) + NET.clearLoopbacks() + } +} + +/** Owns the supplied transport; connections borrow it and share the client UDP binding. */ +class TransportClientNetwork(private val transport: NetworkTransport) : ClientNetwork { + private var closed = false + override fun newChannel() = netchan_t(transport) { System.nanoTime() / 1_000_000L } + + override fun configure(multiplayer: Boolean): Boolean { + check(!closed) { "Client network is closed" } + if (!multiplayer) { + transport.replaceUdp(null) + return true + } + if (transport.udpEndpoint?.isOpen == true) return true + val cvars = Cvar.getInstance() + val ip = cvars.find("ip", String::class.java).typedValue + val port = cvars.find("clientport", Int::class.javaObjectType).typedValue.let { if (it == Defines.PORT_ANY) 0 else it } + return try { + val bindIp = if (ip.isEmpty() || ip == "localhost") null else java.net.InetAddress.getByName(ip) + fun bind(port: Int) = UdpDatagramEndpoint.bind(if (bindIp == null) InetSocketAddress(port) else InetSocketAddress(bindIp, port)) + transport.replaceUdp(try { bind(port) } catch (_: java.io.IOException) { bind(0) }) + true + } catch (error: java.io.IOException) { + Com.Println("Client UDP bind error: $error") + false + } + } + + override fun receive(): NetworkPacket? { + val received = transport.receive() + if (received is ReceiveResult.Failed) Com.Println("Client receive error: ${received.cause}") + if (received !is ReceiveResult.Received) return null + val datagram = received.datagram + val decoded = PacketCodec.decode(datagram.payload, PacketDirection.FROM_SERVER) + return if (decoded is PacketDecodeResult.Decoded) NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) + else null + } + + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { + val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) + require(text.size <= Defines.MAX_MSGLEN - 4) + val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() + val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else + NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) + val result = transport.send(destination, PacketBytes.copyOf(bytes)) + if (result is SendResult.Failed) Com.Println("Client send error: ${result.cause}") + } + + override fun stop() { + if (closed) return + try { + transport.replaceUdp(null) + } finally { + transport.clearLocalIncoming() + } + } + + override fun close() { + if (closed) return + closed = true + transport.close() + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt new file mode 100644 index 00000000..006ee3d9 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt @@ -0,0 +1,50 @@ +package org.demoth.cake.network + +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.DatagramSocket + +class ClientNetworkTest { + @Test + fun `client falls back from occupied UDP port and retains local transport on stop`() { + val cvars = Cvar.getInstance() + cvars.clear() + Netchan.Netchan_Init() + try { + LoopbackLink().use { link -> + val transport = NetworkTransport(link.first, link.second.localAddress) + TransportClientNetwork(transport).use { client -> + DatagramSocket(0).use { occupied -> + cvars.find("clientport", Int::class.javaObjectType).forceSet(occupied.localPort) + assertTrue(client.configure(true)) + val binding = transport.udpEndpoint!! + assertNotEquals(occupied.localPort, binding.localAddress.port) + assertTrue(client.configure(true)) + assertSame(binding, transport.udpEndpoint) + client.stop() + assertFalse(binding.isOpen) + client.sendConnectionless(netadr_t(), ConnectionlessCommand.getchallenge, "\n") + val received = link.second.receive() as ReceiveResult.Received + assertEquals("getchallenge\n", received.datagram.payload.toByteArray() + .drop(4).toByteArray().toString(Charsets.ISO_8859_1)) + val channel = client.newChannel() + channel.setup(jake2.qcommon.Defines.NS_CLIENT, netadr_t(), 42) + assertTrue(channel.transmit(null)) + assertTrue(link.second.receive() is ReceiveResult.Received) + } + client.close() + client.close() + assertTrue(transport.send(transport.localPeer, PacketBytes.copyOf(byteArrayOf(1))) is SendResult.Failed) + } + } + } finally { + cvars.clear() + } + } +} From a0132bac6edbd0569c0afe8e735a3c2f2fee5858 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 11:35:07 +0100 Subject: [PATCH 25/57] Own standalone transports and deprecate compatibility networking --- .../org/demoth/cake/hosting/CakeHost.kt | 7 ++- .../org/demoth/cake/network/ClientNetwork.kt | 1 + .../java/jake2/dedicated/Jake2Dedicated.java | 10 +++- .../jake2/dedicated/DedicatedLifetimeTest.kt | 46 +++++++++++++------ .../main/java/jake2/qcommon/network/NET.java | 2 + .../java/jake2/qcommon/network/netchan_t.java | 4 ++ .../network/runtime/NetworkTransport.kt | 11 +++++ .../network/runtime/NetworkTransportTest.kt | 14 ++++++ .../main/kotlin/jake2/server/ServerNetwork.kt | 1 + 9 files changed, 80 insertions(+), 16 deletions(-) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index f5100fcd..8559d248 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -3,6 +3,9 @@ package org.demoth.cake.hosting import org.demoth.cake.profile.CakeGameProfile import org.demoth.cake.network.ClientNetwork import org.demoth.cake.network.LegacyClientNetwork +import org.demoth.cake.network.TransportClientNetwork +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.LocalSide /** Application-thread boundary between Cake and an optional embedded server. */ interface CakeHost { @@ -65,12 +68,14 @@ class HostFailureException(message: String, cause: Throwable) : RuntimeException /** Remote-only builds can accept the same application wiring without server side effects. */ class UnavailableCakeHost : CakeHost { + override val clientNetwork = TransportClientNetwork( + NetworkTransport.standalone(LocalSide.FIRST)) private val saveUnavailable = "Save/load requires Cake fullgame." override val availability = HostAvailability.REMOTE_ONLY override val state = HostState.STOPPED override fun initialize(startupRejection: () -> String?) = Unit override fun activateProfile(profile: CakeGameProfile?) = Unit - override fun dispose() = Unit + override fun dispose() = clientNetwork.close() override fun start(request: HostRequest) = HostStartResult.Rejected("Hosting requires the Cake fullgame application.") override fun update(elapsedMillis: Long) = Unit diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index 7c328d99..b7d6a38b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -24,6 +24,7 @@ interface ClientNetwork : AutoCloseable { } /** Compatibility composition until application roots supply their own local link. */ +@Deprecated("Compatibility adapter scheduled for removal; inject TransportClientNetwork with an owned NetworkTransport") object LegacyClientNetwork : ClientNetwork { override fun configure(multiplayer: Boolean) = NET.configureClient(multiplayer) override fun newChannel() = netchan_t() diff --git a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index dadbf559..0535071b 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -10,6 +10,10 @@ import jake2.qcommon.sys.Timer; import jake2.qcommon.vfs.EngineFilesystemLifecycle; import jake2.server.ServerHost; +import jake2.server.ServerNetwork; +import jake2.server.TransportServerNetwork; +import jake2.qcommon.network.runtime.NetworkTransport; +import jake2.qcommon.network.transport.LocalSide; import java.util.Arrays; import java.util.List; @@ -64,7 +68,11 @@ public static void main(String[] args) { /** Own the Host through late-command startup and the loop, including exceptional exits. */ static void runHost(List args) { - try (ServerHost host = new ServerHost()) { + runHost(args, new TransportServerNetwork(NetworkTransport.standalone(LocalSide.SECOND))); + } + + static void runHost(List args, ServerNetwork network) { + try (ServerHost host = new ServerHost(network)) { if (Cbuf.AddLateCommands(args)) { Cbuf.AddText("dedicated_start\n"); Cbuf.Execute(); diff --git a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt index cd32ba41..360b55ef 100644 --- a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -5,7 +5,9 @@ import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.LocalSide +import jake2.server.TransportServerNetwork import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach @@ -14,6 +16,14 @@ import org.junit.jupiter.api.Timeout @Timeout(10) class DedicatedLifetimeTest { + private lateinit var transport: NetworkTransport + private lateinit var network: TransportServerNetwork + + private fun runHost(args: List) { + transport = NetworkTransport.standalone(LocalSide.SECOND) + network = TransportServerNetwork(transport) + Jake2Dedicated.runHost(args, network) + } @BeforeEach fun setup() { Cvar.getInstance().clear() @@ -30,7 +40,7 @@ class DedicatedLifetimeTest { Cmd.RemoveCommand("test_end") Cmd.RemoveCommand("test_drop") Cbuf.Execute() - NET.configureServer(false) + if (::network.isInitialized) network.close() Cvar.getInstance().clear() } @@ -39,11 +49,11 @@ class DedicatedLifetimeTest { val failure = IllegalStateException("startup failed") Cmd.AddCommand("test_start") { assertNotNull(Cmd.FindCommand("map")) - assertTrue(NET.configureServer(true)) + assertTrue(network.configure(true)) throw failure } assertSame(failure, assertThrows(IllegalStateException::class.java) { - Jake2Dedicated.runHost(listOf("Jake2", "+test_start")) + runHost(listOf("Jake2", "+test_start")) }) assertReleased() } @@ -51,20 +61,20 @@ class DedicatedLifetimeTest { @Test fun `loop command failure closes Host without swallowing programming errors`() { val failure = IllegalStateException("frame command failed") - Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_start") { assertTrue(network.configure(true)) } Cmd.AddCommand("test_end") { throw failure } assertSame(failure, assertThrows(IllegalStateException::class.java) { - Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) }) assertReleased() } @Test fun `output buffer failure unwinds the loop and closes Host`() { - Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_start") { assertTrue(network.configure(true)) } Cmd.AddCommand("test_end") { jake2.qcommon.sizebuf_t(1).writeInt(123) } val failure = assertThrows(IllegalStateException::class.java) { - Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) } assertTrue(failure.message!!.contains("SZ_GetSpace")) assertReleased() @@ -76,11 +86,11 @@ class DedicatedLifetimeTest { Cmd.AddCommand("test_start") { starts++ assertNotNull(Cmd.FindCommand("map")) - assertTrue(NET.configureServer(true)) + assertTrue(network.configure(true)) } Cmd.AddCommand("test_end") { Thread.currentThread().interrupt() } repeat(2) { - Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) assertTrue(Thread.interrupted(), "Loop must preserve interrupt status for its caller") assertReleased() } @@ -89,7 +99,7 @@ class DedicatedLifetimeTest { @Test fun `legacy recoverable drop retains Host commands and continues loop`() { - Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_start") { assertTrue(network.configure(true)) } Cmd.AddCommand("test_drop") { jake2.qcommon.Com.Error(jake2.qcommon.Defines.ERR_DROP, "recoverable test failure") } @@ -97,17 +107,25 @@ class DedicatedLifetimeTest { Cmd.AddCommand("test_end") { continued = true assertNotNull(Cmd.FindCommand("map")) - assertNull(NET.udpEndpoint(jake2.qcommon.Defines.NS_SERVER)) + assertNull(transport.udpEndpoint) Thread.currentThread().interrupt() } - Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_drop", "+test_end")) + runHost(listOf("Jake2", "+test_start", "+wait", "+test_drop", "+test_end")) assertTrue(continued) assertReleased() } + @Test + fun `default dedicated composition exits and releases commands`() { + Cmd.AddCommand("test_end") { Thread.currentThread().interrupt() } + Jake2Dedicated.runHost(listOf("Jake2", "+test_end")) + assertTrue(Thread.interrupted()) + assertNull(Cmd.FindCommand("map")) + } + private fun assertReleased() { assertNull(Cmd.FindCommand("map")) assertNull(Cmd.FindCommand("gamemap")) - assertNull(NET.udpEndpoint(jake2.qcommon.Defines.NS_SERVER)) + assertNull(transport.udpEndpoint) } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index a133d347..3452c106 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -41,6 +41,8 @@ import static jake2.qcommon.Defines.NS_SERVER; +/** @deprecated Compatibility facade; inject an owned NetworkTransport through the client/server network boundary. */ +@Deprecated public final class NET { /** Legacy address bridge; transport endpoints themselves use immutable addresses. */ diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java index 432e55b6..a8093238 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java @@ -68,10 +68,14 @@ public class netchan_t { private final NetworkTransport transport; private ReliableChannel channel = new ReliableChannel(ChannelRole.CLIENT, 0); + /** @deprecated Inject NetworkTransport and a clock instead of using the global transport. */ + @Deprecated public netchan_t() { this(() -> System.nanoTime() / 1_000_000L); } + /** @deprecated Inject NetworkTransport alongside the clock. */ + @Deprecated public netchan_t(LongSupplier clockMillis) { this(null, clockMillis); } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt index 70b699f7..cce44755 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt @@ -12,6 +12,17 @@ class NetworkTransport( private val local: LoopbackLink.Endpoint, val localPeer: NetworkAddress.Local, ) : AutoCloseable { + companion object { + /** Remote-only application: no in-process peer may send to or receive from this owner. */ + @JvmStatic fun standalone(side: LocalSide): NetworkTransport { + val link = LoopbackLink() + val local = if (side == LocalSide.FIRST) link.first else link.second + val peer = if (side == LocalSide.FIRST) link.second else link.first + peer.close() + return NetworkTransport(local, peer.localAddress) + } + } + var udpEndpoint: UdpDatagramEndpoint? = null private set private var localFirst = true diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt index 5d62c1e5..29815659 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt @@ -13,6 +13,20 @@ class NetworkTransportTest { NetworkTransport(link.second, link.first.localAddress) } + @Test + fun `standalone owners have no local peer and close independently`() { + NetworkTransport.standalone(LocalSide.FIRST).use { client -> + NetworkTransport.standalone(LocalSide.SECOND).use { server -> + assertTrue(client.send(client.localPeer, bytes) is SendResult.Failed) + assertTrue(server.send(server.localPeer, bytes) is SendResult.Failed) + assertEquals(ReceiveResult.Empty, client.receive()) + assertEquals(ReceiveResult.Empty, server.receive()) + client.close() + assertEquals(ReceiveResult.Empty, server.receive()) + } + } + } + private val bytes = PacketBytes.copyOf(byteArrayOf(1, 2, 3)) @Test diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index 1ce7f869..207adee4 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -25,6 +25,7 @@ interface ServerNetwork : AutoCloseable { } /** Compatibility composition until application roots supply their own local link. */ +@Deprecated("Compatibility adapter scheduled for removal; inject TransportServerNetwork with an owned NetworkTransport") internal object LegacyServerNetwork : ServerNetwork { override fun configure(multiplayer: Boolean) = NET.configureServer(multiplayer) override fun newChannel() = netchan_t() From baf8525fdfee0da2986c5e4d5c59d0f41a3ad9c3 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 11:45:33 +0100 Subject: [PATCH 26/57] Share client and server packet I/O and bind policy --- .../org/demoth/cake/network/ClientNetwork.kt | 79 ++---------- .../qcommon/network/runtime/PacketNetwork.kt | 112 ++++++++++++++++++ .../network/runtime/PacketNetworkTest.kt | 83 +++++++++++++ .../main/kotlin/jake2/server/ServerNetwork.kt | 76 ++---------- 4 files changed, 218 insertions(+), 132 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index b7d6a38b..45ee4635 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -1,17 +1,12 @@ package org.demoth.cake.network -import jake2.qcommon.Com import jake2.qcommon.Defines -import jake2.qcommon.exec.Cvar import jake2.qcommon.network.* import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket -import jake2.qcommon.network.protocol.* import jake2.qcommon.network.runtime.NetworkTransport -import jake2.qcommon.network.transport.* -import java.net.InetSocketAddress -import java.nio.ByteBuffer -import java.nio.ByteOrder +import jake2.qcommon.network.runtime.PacketNetwork +import jake2.qcommon.network.runtime.NetworkRole /** Client transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ClientNetwork : AutoCloseable { @@ -37,64 +32,14 @@ object LegacyClientNetwork : ClientNetwork { } } -/** Owns the supplied transport; connections borrow it and share the client UDP binding. */ -class TransportClientNetwork(private val transport: NetworkTransport) : ClientNetwork { - private var closed = false - override fun newChannel() = netchan_t(transport) { System.nanoTime() / 1_000_000L } - - override fun configure(multiplayer: Boolean): Boolean { - check(!closed) { "Client network is closed" } - if (!multiplayer) { - transport.replaceUdp(null) - return true - } - if (transport.udpEndpoint?.isOpen == true) return true - val cvars = Cvar.getInstance() - val ip = cvars.find("ip", String::class.java).typedValue - val port = cvars.find("clientport", Int::class.javaObjectType).typedValue.let { if (it == Defines.PORT_ANY) 0 else it } - return try { - val bindIp = if (ip.isEmpty() || ip == "localhost") null else java.net.InetAddress.getByName(ip) - fun bind(port: Int) = UdpDatagramEndpoint.bind(if (bindIp == null) InetSocketAddress(port) else InetSocketAddress(bindIp, port)) - transport.replaceUdp(try { bind(port) } catch (_: java.io.IOException) { bind(0) }) - true - } catch (error: java.io.IOException) { - Com.Println("Client UDP bind error: $error") - false - } - } - - override fun receive(): NetworkPacket? { - val received = transport.receive() - if (received is ReceiveResult.Failed) Com.Println("Client receive error: ${received.cause}") - if (received !is ReceiveResult.Received) return null - val datagram = received.datagram - val decoded = PacketCodec.decode(datagram.payload, PacketDirection.FROM_SERVER) - return if (decoded is PacketDecodeResult.Decoded) NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) - else null - } - - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { - val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) - require(text.size <= Defines.MAX_MSGLEN - 4) - val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() - val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else - NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) - val result = transport.send(destination, PacketBytes.copyOf(bytes)) - if (result is SendResult.Failed) Com.Println("Client send error: ${result.cause}") - } - - override fun stop() { - if (closed) return - try { - transport.replaceUdp(null) - } finally { - transport.clearLocalIncoming() - } - } - - override fun close() { - if (closed) return - closed = true - transport.close() - } +/** Owns the supplied transport; connections borrow it through the shared packet adapter. */ +class TransportClientNetwork(transport: NetworkTransport) : ClientNetwork { + private val packets = PacketNetwork(transport, NetworkRole.CLIENT) + override fun newChannel() = packets.newChannel() + override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) + override fun receive() = packets.receive() + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + packets.sendConnectionless(to, command, payload) + override fun stop() = packets.stop() + override fun close() = packets.close() } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt new file mode 100644 index 00000000..787fd779 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -0,0 +1,112 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Com +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.* +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder + +enum class NetworkRole { CLIENT, SERVER } + +/** Shared engine adapter: bind policy, packet envelopes and transport lifetime for one owner. */ +class PacketNetwork( + private val transport: NetworkTransport, + private val role: NetworkRole, +) : AutoCloseable { + private var closed = false + private val label = if (role == NetworkRole.CLIENT) "Client" else "Server" + private val direction = if (role == NetworkRole.CLIENT) PacketDirection.FROM_SERVER else PacketDirection.FROM_CLIENT + + fun newChannel(): netchan_t { + check(!closed) { "$label network is closed" } + return netchan_t(transport) { System.nanoTime() / 1_000_000L } + } + + /** Reuse the binding until explicitly disabled. Only clients fall back to an ephemeral port. */ + fun configure(enabled: Boolean): Boolean { + check(!closed) { "$label network is closed" } + if (!enabled) { + transport.replaceUdp(null) + return true + } + if (transport.udpEndpoint?.isOpen == true) return true + val cvars = Cvar.getInstance() + val ip = cvars.find("ip", String::class.java).typedValue + val portName = if (role == NetworkRole.CLIENT) "clientport" else "port" + val port = cvars.find(portName, Int::class.javaObjectType).typedValue + .let { if (it == Defines.PORT_ANY) 0 else it } + return try { + val bindIp = if (ip.isEmpty() || ip == "localhost") null else InetAddress.getByName(ip) + fun bind(port: Int) = UdpDatagramEndpoint.bind( + if (bindIp == null) InetSocketAddress(port) else InetSocketAddress(bindIp, port)) + val endpoint = try { + bind(port) + } catch (error: IOException) { + if (role != NetworkRole.CLIENT) throw error + bind(0) + } + transport.replaceUdp(endpoint) + true + } catch (error: IOException) { + Com.Println("$label UDP bind error: $error") + false + } + } + + /** Compatibility nullable view; explicit transport/codec outcomes remain below this adapter. */ + fun receive(): NetworkPacket? { + val received = transport.receive() + if (received is ReceiveResult.Failed) Com.Println("$label receive error: ${received.cause}") + if (received !is ReceiveResult.Received) return null + val datagram = received.datagram + val decoded = PacketCodec.decode(datagram.payload, direction) + if (decoded is PacketDecodeResult.Rejected) { + if (Netchan.showdrop?.typedValue == true) + Com.Println("$label dropped packet from ${datagram.source}: ${decoded.reason}") + return null + } + val packet = NetworkPacket.fromEnvelope(datagram.source, (decoded as PacketDecodeResult.Decoded).envelope) + if (packet.isConnectionless && Netchan.printNetwork?.typedValue == true) { + val peer = if (role == NetworkRole.CLIENT) "server" else "client" + Com.Printf("Network: Received from $peer connectionless: ${packet.connectionlessMessage}\n") + } + return packet + } + + fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { + val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) + require(text.size <= Defines.MAX_MSGLEN - 4) + val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() + val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else + NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) + val result = transport.send(destination, PacketBytes.copyOf(bytes)) + if (result is SendResult.Failed) Com.Println("$label send error: ${result.cause}") + } + + /** Session boundary: keep the local endpoint and final messages queued for its peer. */ + fun stop() { + if (closed) return + try { + transport.replaceUdp(null) + } finally { + transport.clearLocalIncoming() + } + } + + override fun close() { + if (closed) return + closed = true + transport.close() + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt new file mode 100644 index 00000000..a591f454 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -0,0 +1,83 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.DatagramSocket + +class PacketNetworkTest { + @Test + fun `shared packet adapter preserves direction and Latin-1 packet size boundaries`() { + LoopbackLink().use { link -> + val clientTransport = NetworkTransport(link.first, link.second.localAddress) + val serverTransport = NetworkTransport(link.second, link.first.localAddress) + PacketNetwork(clientTransport, NetworkRole.CLIENT).use { client -> + PacketNetwork(serverTransport, NetworkRole.SERVER).use { server -> + val clientChannel = client.newChannel() + clientChannel.setup(Defines.NS_CLIENT, netadr_t(), 1234) + assertTrue(clientChannel.transmit(null)) + val request = server.receive()!! + assertEquals(1234, request.qport) + assertEquals(10, request.buffer.readcount) + + val serverChannel = server.newChannel() + serverChannel.setup(Defines.NS_SERVER, netadr_t(), 1234) + assertTrue(serverChannel.accept(request)) + assertTrue(serverChannel.transmit(null)) + val reply = client.receive()!! + assertEquals(8, reply.buffer.readcount) + assertTrue(clientChannel.accept(reply)) + + val body = "é".repeat(Defines.MAX_MSGLEN - 4 - "print\n".length) + server.sendConnectionless(netadr_t(), ConnectionlessCommand.print, "\n$body") + val text = client.receive()!! + assertEquals(Defines.MAX_MSGLEN, text.buffer.cursize) + assertEquals("print", text.connectionlessMessage) + assertEquals(body, text.connectionlessParameters) + assertThrows(IllegalArgumentException::class.java) { + server.sendConnectionless(netadr_t(), ConnectionlessCommand.print, "\n${body}é") + } + assertNull(client.receive()) + } + } + } + } + + @Test + fun `server bind failure never falls back and legacy wildcard port works with explicit IP`() { + val cvars = Cvar.getInstance() + cvars.clear() + Netchan.Netchan_Init() + val transport = NetworkTransport.standalone(LocalSide.SECOND) + try { + PacketNetwork(transport, NetworkRole.SERVER).use { server -> + DatagramSocket(0).use { occupied -> + cvars.find("port", Int::class.javaObjectType).forceSet(occupied.localPort) + assertFalse(server.configure(true)) + assertNull(transport.udpEndpoint) + } + cvars.find("ip", String::class.java).forceSet("127.0.0.1") + cvars.find("port", Int::class.javaObjectType).forceSet(Defines.PORT_ANY) + assertTrue(server.configure(true)) + val binding = transport.udpEndpoint!! + assertTrue(binding.localAddress.port > 0) + assertTrue(server.configure(true)) + assertSame(binding, transport.udpEndpoint) + server.configure(false) + assertFalse(binding.isOpen) + assertNull(transport.udpEndpoint) + server.close() + server.stop() + assertThrows(IllegalStateException::class.java) { server.configure(true) } + assertThrows(IllegalStateException::class.java) { server.newChannel() } + } + } finally { + cvars.clear() + } + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index 207adee4..aa386c32 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -1,17 +1,12 @@ package jake2.server -import jake2.qcommon.Com import jake2.qcommon.Defines -import jake2.qcommon.exec.Cvar import jake2.qcommon.network.* import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket -import jake2.qcommon.network.protocol.* import jake2.qcommon.network.runtime.NetworkTransport -import jake2.qcommon.network.transport.* -import java.net.InetSocketAddress -import java.nio.ByteBuffer -import java.nio.ByteOrder +import jake2.qcommon.network.runtime.PacketNetwork +import jake2.qcommon.network.runtime.NetworkRole /** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ServerNetwork : AutoCloseable { @@ -39,59 +34,16 @@ internal object LegacyServerNetwork : ServerNetwork { override fun sleep(millis: Int) = NET.Sleep(millis) } -/** Owns the supplied transport; connections borrow it and share the server UDP binding. */ +/** Owns the supplied transport; connections borrow it through the shared packet adapter. */ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNetwork { - private var closed = false - override fun newChannel() = netchan_t(transport) { System.nanoTime() / 1_000_000L } - - override fun configure(multiplayer: Boolean): Boolean { - check(!closed) { "Server network is closed" } - if (!multiplayer) { - transport.replaceUdp(null) - return true - } - if (transport.udpEndpoint?.isOpen == true) return true - val cvars = Cvar.getInstance() - val ip = cvars.find("ip", String::class.java).typedValue - val port = cvars.find("port", Int::class.javaObjectType).typedValue.let { if (it == Defines.PORT_ANY) 0 else it } - return try { - transport.replaceUdp(UdpDatagramEndpoint.bind( - if (ip.isEmpty() || ip == "localhost") InetSocketAddress(port) else InetSocketAddress(java.net.InetAddress.getByName(ip), port))) - true - } catch (error: java.io.IOException) { - Com.Println("Server UDP bind error: $error") - false - } - } - - override fun receive(): NetworkPacket? { - val received = transport.receive() - if (received is ReceiveResult.Failed) Com.Println("Server receive error: ${received.cause}") - if (received !is ReceiveResult.Received) return null - val datagram = received.datagram - val decoded = PacketCodec.decode(datagram.payload, PacketDirection.FROM_CLIENT) - return if (decoded is PacketDecodeResult.Decoded) NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) - else null - } - - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { - val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) - require(text.size <= Defines.MAX_MSGLEN - 4) - val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() - val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else - NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) - val result = transport.send(destination, PacketBytes.copyOf(bytes)) - if (result is SendResult.Failed) Com.Println("Server send error: ${result.cause}") - } - - override fun stop() { - if (closed) return - try { - transport.replaceUdp(null) - } finally { - transport.clearLocalIncoming() - } - } + private val packets = PacketNetwork(transport, NetworkRole.SERVER) + override fun newChannel() = packets.newChannel() + override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) + override fun receive() = packets.receive() + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + packets.sendConnectionless(to, command, payload) + override fun stop() = packets.stop() + override fun close() = packets.close() override fun sleep(millis: Int) { if (transport.udpEndpoint == null) return @@ -101,10 +53,4 @@ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNe Thread.currentThread().interrupt() } } - - override fun close() { - if (closed) return - closed = true - transport.close() - } } From c10dfeb1a97d4f89a5c4979e234cc7366a1e194a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 12:09:37 +0100 Subject: [PATCH 27/57] Remove legacy networking defaults and isolate host fixtures --- .../src/main/java/jake2/fullgame/Jake2.java | 2 +- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 33 +++------- .../java/jake2/server/GameImportsTest.java | 24 +++---- .../kotlin/jake2/server/CakeSessionPeer.kt | 3 +- .../jake2/server/EmbeddedCakeHostTest.kt | 20 +++--- .../jake2/server/EmbeddedCakeSavesTest.kt | 2 - .../jake2/server/GameContinuationTest.kt | 16 +++-- .../jake2/server/HostClientAccountingTest.kt | 28 ++++---- .../kotlin/jake2/server/HostNetworkFixture.kt | 39 +++++++++++ .../server/HostSessionPreparationTest.kt | 64 ++++++++++--------- .../jake2/server/TransitionArchiveTest.kt | 2 +- .../org/demoth/cake/hosting/CakeHost.kt | 2 - .../org/demoth/cake/network/ClientNetwork.kt | 15 ----- .../main/kotlin/jake2/server/ServerHost.kt | 2 +- .../main/kotlin/jake2/server/ServerNetwork.kt | 21 ++---- .../jake2/server/AirAccelerationTest.java | 8 +-- .../server/GameCvarInitializationTest.java | 12 ++-- .../kotlin/jake2/server/GameModeCvarsTest.kt | 2 +- .../jake2/server/LocalAirAccelerationTest.kt | 2 +- 19 files changed, 149 insertions(+), 148 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt diff --git a/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java b/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java index eb21a6f9..866d5b5d 100644 --- a/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java +++ b/cake/cake-fullgame/src/main/java/jake2/fullgame/Jake2.java @@ -31,6 +31,6 @@ of the License, or (at your option) any later version. /** Desktop composition root for Cake with the embedded server distribution. */ public final class Jake2 { public static void main(String[] args) { - CakeDesktopLauncher.launch(args, EmbeddedCakeHost.owned()); + CakeDesktopLauncher.launch(args, new EmbeddedCakeHost()); } } diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index a1e81f30..8612d09d 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -11,8 +11,6 @@ import jake2.qcommon.network.transport.LoopbackLink import jake2.qcommon.network.runtime.NetworkTransport import jake2.server.TransportServerNetwork import org.demoth.cake.network.TransportClientNetwork -import org.demoth.cake.network.LegacyClientNetwork -import jake2.qcommon.network.NET import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.vfs.VfsConfig @@ -33,28 +31,18 @@ import java.util.UUID /** Application-owned embedded Host; games may stop while commands and profile remain active. */ class EmbeddedCakeHost( private val profilesRoot: Path = Path.of(System.getProperty("user.home"), ".cake"), - private val localLink: LoopbackLink? = null, + private val localLink: LoopbackLink = LoopbackLink(), ) : CakeHost { - companion object { - @JvmStatic fun owned() = EmbeddedCakeHost(localLink = LoopbackLink()) - } - private val clientTransport = localLink?.let { - NetworkTransport(it.first, it.second.localAddress) - } - private val serverTransport = localLink?.let { - NetworkTransport(it.second, it.first.localAddress) - } - override val clientNetwork = clientTransport?.let { TransportClientNetwork(it) } - ?: LegacyClientNetwork + private val clientTransport = NetworkTransport(localLink.first, localLink.second.localAddress) + private val serverTransport = NetworkTransport(localLink.second, localLink.first.localAddress) + override val clientNetwork = TransportClientNetwork(clientTransport) - internal fun udpEndpoint(role: Int) = if (localLink == null) NET.udpEndpoint(role) - else if (role == Defines.NS_CLIENT) clientTransport?.udpEndpoint else serverTransport?.udpEndpoint + internal fun udpEndpoint(role: Int) = + if (role == Defines.NS_CLIENT) clientTransport.udpEndpoint else serverTransport.udpEndpoint private fun clearLocalTraffic() { - if (localLink == null) NET.clearLoopbacks() else { - localLink.first.clearIncoming() - localLink.second.clearIncoming() - } + localLink.first.clearIncoming() + localLink.second.clearIncoming() } internal var server: ServerHost? = null @@ -80,8 +68,7 @@ class EmbeddedCakeHost( else -> this.startupRejection() } } - server = if (serverTransport == null) ServerHost(startupRejection = rejection) - else ServerHost(TransportServerNetwork(serverTransport), rejection) + server = ServerHost(TransportServerNetwork(serverTransport), rejection) } override fun activateProfile(profile: CakeGameProfile?) { @@ -215,7 +202,7 @@ class EmbeddedCakeHost( manager?.close() } finally { if (manager != null) clearLocalTraffic() - if (localLink != null) clientNetwork.close() + try { clientNetwork.close() } finally { localLink.close() } previousWriteRoot?.let(EngineWriteRoot::setRoot) previousWriteRoot = null activeProfile = null diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 8cb7d4d7..65fb8a46 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -55,7 +55,7 @@ public void cleanup() { @Test public void runEmptyInstance() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl testInstance = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); testInstance.runFrame(); @@ -65,7 +65,7 @@ public void runEmptyInstance() { @Test public void gamesKeepIndependentMapStateAndFrameCounters() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); @@ -91,7 +91,7 @@ public void gamesKeepIndependentMapStateAndFrameCounters() { @Test public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); @@ -129,7 +129,7 @@ public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { @Test public void spatialQueriesAndClearingStayWithTheirGame() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); ServerEntity firstEntity = first.gameExports.getEdict(1); @@ -167,7 +167,7 @@ public void spatialQueriesAndClearingStayWithTheirGame() { @Test public void clientSnapshotsKeepIndependentGameHistory() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); ClientNetworkInfo firstClient = snapshotClient(first, 11); @@ -229,7 +229,7 @@ private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { @Test public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); @@ -283,7 +283,7 @@ public void clientPrintQueueRespectsItsMessageLevel() { @Test public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); GameExportsImpl firstGameplay = (GameExportsImpl) first.gameExports; Assertions.assertEquals(ServerStates.SS_GAME, first.state); @@ -307,7 +307,7 @@ public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() @Test public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl original = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); GameExportsImpl originalGameplay = (GameExportsImpl) original.gameExports; originalGameplay.game.serverflags = 9; @@ -346,7 +346,7 @@ public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { @Test public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); Cvar.getInstance().register("gamedir", jake2.qcommon.exec.CvarTypes.STRING, "", 0, "Test mod directory"); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); @@ -380,7 +380,7 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { // A separate idle Host must not inherit or clear the running Host's registry. // Global console/cvar/network isolation is outside this ownership test. - try (ServerHost other = new ServerHost()) { + try (ServerHost other = new ServerHost(TransportServerNetwork.standalone())) { Assertions.assertFalse(other.isRunning()); Assertions.assertEquals(first.name, other.allocateGameName()); } @@ -392,7 +392,7 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { @Test public void saveCommandResolvesOnlyDefaultGameWithoutReregistration() throws Exception { - manager = new ServerHost(); + manager = new ServerHost(TransportServerNetwork.standalone()); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); var saveRegistration = jake2.qcommon.exec.Cmd.FindCommand("save"); @@ -422,7 +422,7 @@ public void saveCommandResolvesOnlyDefaultGameWithoutReregistration() throws Exc @Test public void runGame() { - final ServerHost sv_main = manager = new ServerHost(); + final ServerHost sv_main = manager = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl game = sv_main.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); final GameExports gameExports = game.gameExports; GameEntity gunner = null; diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt index 05367566..f77dfee4 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt @@ -9,7 +9,6 @@ import jake2.qcommon.MainCommon import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET import jake2.qcommon.network.Netchan import org.demoth.cake.Cake import org.demoth.cake.CakeStartupBootstrap @@ -37,7 +36,7 @@ object CakeSessionPeer { try { host.initialize() check(host.start(HostRequest(profile, "base1", HostMode.DEATHMATCH)) == HostStartResult.Started) - Files.writeString(ready, NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port.toString()) + Files.writeString(ready, host.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port.toString()) var previous = System.nanoTime() while (System.nanoTime() < deadline) { Thread.sleep(5) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index a2a35985..f71c3f3e 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -6,7 +6,6 @@ import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.server.DownloadMessage import jake2.qcommon.network.messages.server.ServerMessage @@ -50,7 +49,6 @@ class EmbeddedCakeHostTest { @AfterEach fun cleanup() { host.dispose() - NET.Config(false) EngineWriteRoot.setRoot(previousRoot) cvars.clear() } @@ -81,7 +79,7 @@ class EmbeddedCakeHostTest { assertEquals(HostState.STOPPED, host.state) assertEquals(previousRoot, EngineWriteRoot.path()) assertFalse(Files.exists(temp.resolve("session"))) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) } @Test @@ -109,7 +107,7 @@ class EmbeddedCakeHostTest { assertNull(Cmd.FindCommand("load")) assertNull(Cmd.FindCommand("save")) assertEquals(previousRoot, EngineWriteRoot.path()) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) assertThrows(IllegalStateException::class.java) { host.initialize() } } @@ -188,7 +186,7 @@ class EmbeddedCakeHostTest { host.update(100) assertEquals(HostState.RUNNING, host.state) assertEquals(1, host.server!!.games.getValue("default").serverMain.clients.size) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) } @Test @@ -237,7 +235,7 @@ class EmbeddedCakeHostTest { assertEquals("", cvars.find("gamedir").typedValue) assertEquals(temp.resolve("session"), EngineWriteRoot.path()) assertTrue(Files.exists(temp.resolve("session/save/autosave/game.ssv.json"))) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) val frame = game().level.framenum host.update(100) assertEquals(frame + 1, game().level.framenum) @@ -306,14 +304,14 @@ class EmbeddedCakeHostTest { fun `multiplayer continues while menus request pause`() { for (mode in listOf(HostMode.DEATHMATCH, HostMode.COOP)) { assertEquals(HostStartResult.Started, host.start(request(mode))) - assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(host.udpEndpoint(Defines.NS_SERVER)) val frame = game().level.framenum host.setPaused(true) host.update(100) assertFalse(cvars.find("paused").typedValue) assertEquals(frame + 1, game().level.framenum) host.stop("next mode") - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) } } @@ -323,12 +321,12 @@ class EmbeddedCakeHostTest { cvars.find("port").forceSet(occupied.localPort) assertInstanceOf(HostStartResult.Rejected::class.java, host.start(request(HostMode.COOP))) assertEquals(HostState.STOPPED, host.state) - assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { NET.udpEndpoint(it) == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) assertNotNull(Cmd.FindCommand("map")) } assertEquals(HostStartResult.Started, host.start(request(HostMode.COOP))) - assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) - assertNull(NET.udpEndpoint(Defines.NS_CLIENT)) + assertNotNull(host.udpEndpoint(Defines.NS_SERVER)) + assertNull(host.udpEndpoint(Defines.NS_CLIENT)) } @Test diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index fa107ca0..a53f009e 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -6,7 +6,6 @@ import jake2.qcommon.MainCommon import jake2.qcommon.ServerStates import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET import jake2.qcommon.network.Netchan import jake2.qcommon.vfs.EngineWriteRoot import jake2.server.save.ServerSaveFiles @@ -43,7 +42,6 @@ class EmbeddedCakeSavesTest { @AfterEach fun cleanup() { host.dispose() - NET.Config(false) EngineWriteRoot.setRoot(previousRoot) cvars.clear() } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 9d5f6a78..c96ce36a 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -13,7 +13,6 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import java.nio.file.Files -import jake2.qcommon.network.NET import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource @@ -21,6 +20,7 @@ class GameContinuationTest { @TempDir lateinit var root: Path private lateinit var previousRoot: Path private var previousState: ServerStates? = null + private lateinit var network: HostNetworkFixture private lateinit var host: ServerHost @BeforeEach @@ -34,7 +34,8 @@ class GameContinuationTest { EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) - host = ServerHost() + network = HostNetworkFixture() + host = ServerHost(network.server) Cvar.getInstance().find("maxclients").forceSet(3) host.resetClients() } @@ -43,6 +44,7 @@ class GameContinuationTest { fun cleanup() { host.clients.forEach { it.state = ClientStates.CS_FREE } host.close() + network.close() Cbuf.ClearDeferred() EngineWriteRoot.setRoot(previousRoot) Globals.server_state = previousState @@ -192,8 +194,8 @@ class GameContinuationTest { a.netchan.remote_address = null // Fail the first final notification. first.gameExports = null // Discarding a Game must not call back into its gameplay. Cvar.getInstance().find("port").forceSet(0) - assertTrue(NET.configureServer(true)) - val socket = NET.udpEndpoint(Defines.NS_SERVER) + assertTrue(network.server.configure(true)) + val socket = network.serverTransport.udpEndpoint val peerChannel = b.netchan host.stopGame(first, "Game failed\n") @@ -207,7 +209,7 @@ class GameContinuationTest { assertTrue(client.unreliable.isEmpty()) } assertSame(second, host.games["other"]) - assertSame(socket, NET.udpEndpoint(Defines.NS_SERVER)) + assertSame(socket, network.serverTransport.udpEndpoint) assertSame(peerChannel, b.netchan) assertEquals(ClientStates.CS_CONNECTED, b.state) host.update(100) @@ -378,7 +380,7 @@ class GameContinuationTest { finish(bind(1, second), second) Cvar.getInstance().find("paused").forceSet(true) Cvar.getInstance().find("port").forceSet(0) - assertTrue(NET.configureServer(true)) + assertTrue(network.server.configure(true)) val broken = when (stage) { "wipe" -> root.resolve("save/current/game.ssv.json") "autosave" -> root.resolve("save/autosave") @@ -395,7 +397,7 @@ class GameContinuationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(network.serverTransport.udpEndpoint) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("gamemap")) host.update(100) assertTrue(host.games.isEmpty(), "Remaining queued continuation must not restart a Game") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index c5fd3fe2..988fea97 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -17,6 +17,7 @@ class HostClientAccountingTest { @TempDir lateinit var root: Path private lateinit var previousRoot: Path private var previousState: ServerStates? = null + private lateinit var network: HostNetworkFixture private lateinit var host: ServerHost private lateinit var first: GameImportsImpl private lateinit var second: GameImportsImpl @@ -30,11 +31,11 @@ class HostClientAccountingTest { cvars.clear() MainCommon.registerCvars(false) Netchan.Netchan_Init() - drainPackets() EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) - host = ServerHost() + network = HostNetworkFixture() + host = ServerHost(network.server) assertTrue(host.startMap("testbox")) host.games.clear() cvars.find("maxclients").forceSet(2) @@ -69,6 +70,7 @@ class HostClientAccountingTest { fun cleanup() { host.clients.forEach { it.state = ClientStates.CS_FREE } host.close() + network.close() drainPackets() EngineWriteRoot.setRoot(previousRoot) Globals.server_state = previousState @@ -77,7 +79,7 @@ class HostClientAccountingTest { private fun drainPackets() { for (side in listOf(Defines.NS_CLIENT, Defines.NS_SERVER)) { - while (NET.receiveLoopbackPacket(side) != null) { } + while (network.receiveLocal(side) != null) { } } } @@ -309,12 +311,12 @@ class HostClientAccountingTest { } cvars.find("port").forceSet(0) try { - assertTrue(NET.configureServer(true)) + assertTrue(network.server.configure(true)) Globals.curtime = 1000 host.handleConnectionlessPacket(address, "getchallenge") val issued = reply() val challenge = issued.substringAfterLast(' ') - NET.configureServer(false) + network.server.configure(false) // No UDP socket while filling synthetic remote addresses: no external packets. Globals.curtime = -1000 repeat(Defines.MAX_CHALLENGES - 1) { index -> @@ -324,21 +326,21 @@ class HostClientAccountingTest { } host.handleConnectionlessPacket(remote, "getchallenge") } - assertTrue(NET.configureServer(true)) + assertTrue(network.server.configure(true)) host.handleConnectionlessPacket(address, "getchallenge") assertEquals(issued, reply(), "Repeated requests retain the existing challenge") - NET.configureServer(false) + network.server.configure(false) host.handleConnectionlessPacket(netadr_t().apply { type = NetAddrType.NA_IP ip = byteArrayOf(198.toByte(), 51, 100, 1) }, "getchallenge") - assertTrue(NET.configureServer(true)) + assertTrue(network.server.configure(true)) host.handleConnectionlessPacket(address, "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester") assertTrue(reply().contains("No challenge for address")) } finally { Globals.curtime = previousTime - NET.configureServer(false) + network.server.configure(false) } } } @@ -350,11 +352,11 @@ class HostClientAccountingTest { val loopback = java.net.InetAddress.getByName("127.0.0.1") java.net.DatagramSocket(0, loopback).use { receiver -> receiver.soTimeout = 2000 - assertTrue(NET.configureServer(true)) + assertTrue(network.server.configure(true)) try { val request = byteArrayOf(-1, -1, -1, -1) + "status".toByteArray() receiver.send(java.net.DatagramPacket(request, request.size, loopback, - NET.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port)) + network.serverTransport.udpEndpoint!!.localAddress.port)) val response = java.net.DatagramPacket(ByteArray(2048), 2048) // The UDP receive path is nonblocking; allow delivery before pumping it again. val deadline = System.nanoTime() + 2_000_000_000L @@ -375,7 +377,7 @@ class HostClientAccountingTest { assertEquals(first.mapName, Info.Info_ValueForKey(info, "mapname")) assertFalse(text.contains("another-game-map")) } finally { - NET.configureServer(false) + network.server.configure(false) } } } @@ -385,7 +387,7 @@ class HostClientAccountingTest { // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. val address = netadr_t().apply { port = 12345 } host.handleConnectionlessPacket(address, "getchallenge") - val reply = NET.receiveLoopbackPacket(Defines.NS_CLIENT)!!.connectionlessMessage + val reply = network.receiveLocal(Defines.NS_CLIENT)!!.connectionlessMessage val challenge = reply.trim().substringAfterLast(' ') val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" first.frameNum = 9000 diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt new file mode 100644 index 00000000..5c1eb7c2 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt @@ -0,0 +1,39 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.runtime.* +import jake2.qcommon.network.transport.* + +/** Private client/server resources for a Host test, never shared through the legacy facade. */ +internal class HostNetworkFixture : AutoCloseable { + private val link = LoopbackLink() + val serverTransport = NetworkTransport(link.second, link.first.localAddress) + val clientTransport = NetworkTransport(link.first, link.second.localAddress) + val server = TransportServerNetwork(serverTransport) + val client = PacketNetwork(clientTransport, NetworkRole.CLIENT) + + fun clearLocalTraffic() { + link.first.clearIncoming() + link.second.clearIncoming() + } + + fun receiveLocal(role: Int): NetworkPacket? { + val endpoint = if (role == Defines.NS_CLIENT) link.first else link.second + val received = endpoint.receive() as? ReceiveResult.Received ?: return null + val datagram = received.datagram + val direction = if (role == Defines.NS_CLIENT) PacketDirection.FROM_SERVER else PacketDirection.FROM_CLIENT + val decoded = PacketCodec.decode(datagram.payload, direction) as? PacketDecodeResult.Decoded ?: return null + return NetworkPacket.fromEnvelope(datagram.source, decoded.envelope) + } + + fun sendLocal(role: Int, length: Int, bytes: ByteArray) { + val transport = if (role == Defines.NS_CLIENT) clientTransport else serverTransport + transport.send(transport.localPeer, PacketBytes.copyOf(bytes, length)) + } + + override fun close() { + try { server.close() } finally { client.close() } + } +} diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 166758d6..69512c04 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -3,7 +3,6 @@ package jake2.server import jake2.qcommon.* import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET import jake2.qcommon.vfs.* import jake2.server.save.ServerSaveFiles import org.junit.jupiter.api.* @@ -18,6 +17,7 @@ class HostSessionPreparationTest { @TempDir lateinit var root: Path private lateinit var previousRoot: Path private var previousState: ServerStates? = null + private lateinit var network: HostNetworkFixture private lateinit var host: ServerHost private val cvars = Cvar.getInstance() @@ -33,13 +33,14 @@ class HostSessionPreparationTest { EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) - host = ServerHost() + network = HostNetworkFixture() + host = ServerHost(network.server) } @AfterEach fun cleanup() { host.close() - NET.configureServer(false) + network.close() cvars.clear() EngineWriteRoot.setRoot(previousRoot) Globals.server_state = previousState @@ -84,7 +85,7 @@ class HostSessionPreparationTest { fun `load after stop prepares saved capacity before Game allocation`(coop: Boolean) { save(coop) host.stop("test", false) - NET.configureServer(false) + network.server.configure(false) cvars.find("coop").forceSet(!coop) cvars.find("maxclients").forceSet(if (coop) 1 else 4) @@ -97,7 +98,7 @@ class HostSessionPreparationTest { assertEquals(coop, loaded.isCoop) assertEquals(!coop, cvars.find("coop").typedValue, "Loading must preserve Host mode defaults") assertTrue(host.clients.all { it.gameName == "default" }) - assertEquals(coop, NET.udpEndpoint(Defines.NS_SERVER) != null) + assertEquals(coop, network.serverTransport.udpEndpoint != null) // Exercise the snapshot ring allocated from the prepared client capacity. host.clients[0].edict = loaded.gameExports.getEdict(1) loaded.buildClientFrameMessages(host.clients[0]) @@ -107,7 +108,9 @@ class HostSessionPreparationTest { fun `fresh Host loads save and can restart again`() { save(false) host.close() - host = ServerHost() + network.close() + network = HostNetworkFixture() + host = ServerHost(network.server) host.loadGame(listOf("load", "slot")) assertEquals(1, host.clients.size) assertEquals("testbox", host.games.getValue("default").mapName) @@ -344,10 +347,10 @@ class HostSessionPreparationTest { host.games[other.name] = other host.clients[1].gameName = other.name cvars.find("paused").forceSet(true) - assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(network.serverTransport.udpEndpoint) if (stage == "idle") { host.stop("test", false) - NET.configureServer(false) + network.server.configure(false) } val path = when (stage) { "copy" -> root.resolve("save/current/game.ssv.json") @@ -375,7 +378,7 @@ class HostSessionPreparationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(network.serverTransport.udpEndpoint) assertEquals(ServerStates.SS_DEAD, Globals.server_state) assertEquals("", cvars.find("mapname").typedValue) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("load")) @@ -400,13 +403,13 @@ class HostSessionPreparationTest { fun `load port failure leaves reusable idle Host`() { save(true) host.stop("test", false) - NET.configureServer(false) + network.server.configure(false) java.net.DatagramSocket(0).use { occupied -> cvars.find("port").forceSet(occupied.localPort) assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.loadGame("slot")) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(network.serverTransport.udpEndpoint) } cvars.find("port").forceSet(0) assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) @@ -458,7 +461,7 @@ class HostSessionPreparationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(network.serverTransport.udpEndpoint) assertEquals(ServerStates.SS_DEAD, Globals.server_state) assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) Files.delete(blocked) @@ -490,16 +493,16 @@ class HostSessionPreparationTest { @Test fun `stop owns cleanup and preserves final packets and client socket`() { save(true) - NET.clearLoopbacks() + network.clearLocalTraffic() cvars.find("clientport").forceSet(0) - assertTrue(NET.configureClient(true)) - val clientSocket = NET.udpEndpoint(Defines.NS_CLIENT) - val serverEndpoint = NET.udpEndpoint(Defines.NS_SERVER) + assertTrue(network.client.configure(true)) + val clientSocket = network.clientTransport.udpEndpoint!! + val serverEndpoint = network.serverTransport.udpEndpoint!! val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") val client = host.clients[0] client.state = ClientStates.CS_CONNECTED client.netchan.setup(Defines.NS_SERVER, jake2.qcommon.network.netadr_t(), 0) - NET.SendLoopPacket(Defines.NS_CLIENT, 8, ByteArray(8), jake2.qcommon.network.netadr_t()) + network.sendLocal(Defines.NS_CLIENT, 8, ByteArray(8)) cvars.find("paused").forceSet(true) var deferredRan = false jake2.qcommon.exec.Cmd.AddCommand("test_stopped_deferred") { deferredRan = true } @@ -508,19 +511,19 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.CopyToDefer() host.stop("test stop", false) assertFalse(serverEndpoint.isOpen) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) - assertSame(clientSocket, NET.udpEndpoint(Defines.NS_CLIENT)) + assertNull(network.serverTransport.udpEndpoint) + assertSame(clientSocket, network.clientTransport.udpEndpoint) assertTrue(clientSocket.isOpen) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) assertEquals("", cvars.find("mapname").typedValue) assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) - assertNull(NET.receiveLoopbackPacket(Defines.NS_SERVER)) - assertNotNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) - assertNotNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) + assertNull(network.receiveLocal(Defines.NS_SERVER)) + assertNotNull(network.receiveLocal(Defines.NS_CLIENT)) + assertNotNull(network.receiveLocal(Defines.NS_CLIENT)) host.stop("again", false) - assertNull(NET.receiveLoopbackPacket(Defines.NS_CLIENT)) + assertNull(network.receiveLocal(Defines.NS_CLIENT)) jake2.qcommon.exec.Cbuf.InsertFromDefer() jake2.qcommon.exec.Cbuf.Execute() assertFalse(deferredRan) @@ -529,8 +532,8 @@ class HostSessionPreparationTest { assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) } finally { jake2.qcommon.exec.Cmd.RemoveCommand("test_stopped_deferred") - NET.configureClient(false) - NET.clearLoopbacks() + network.client.configure(false) + network.clearLocalTraffic() } } @@ -542,7 +545,7 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.Execute() assertEquals("testbox2", host.games.getValue("default").mapName) assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) - assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(network.serverTransport.udpEndpoint) } @Test @@ -554,10 +557,11 @@ class HostSessionPreparationTest { assertThrows(NullPointerException::class.java) { host.close() } assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) - assertNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNull(network.serverTransport.udpEndpoint) assertFalse(host.isPaused) assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) host.close() + network.close() } @ParameterizedTest @@ -581,7 +585,7 @@ class HostSessionPreparationTest { assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) assertTrue(host.isRunning) - assertNotNull(NET.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(network.serverTransport.udpEndpoint) host.update(100) if (stage != "destination") Files.delete(blocked.resolve("blocker")) Files.delete(blocked) @@ -643,7 +647,7 @@ class HostSessionPreparationTest { assertNull(EngineVfs.loadBytes("maps.lst"), "Fixture must have no optional map list") val games = host.games.toMap() val clients = host.clients.toList() - val socket = NET.udpEndpoint(Defines.NS_SERVER) + val socket = network.serverTransport.udpEndpoint val output = StringBuilder() val sink = Com.ConsoleSink { _, message -> output.append(message) } var continued = false @@ -658,7 +662,7 @@ class HostSessionPreparationTest { assertEquals(running, host.isRunning) assertEquals(games, host.games) assertEquals(clients, host.clients.toList()) - assertSame(socket, NET.udpEndpoint(Defines.NS_SERVER)) + assertSame(socket, network.serverTransport.udpEndpoint) } finally { Com.ClearConsoleSink(sink) jake2.qcommon.exec.Cmd.RemoveCommand("test_after_listmaps") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index c8d809ab..6876f285 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -33,7 +33,7 @@ class TransitionArchiveTest { EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) - host = ServerHost() + host = ServerHost(TransportServerNetwork.standalone()) Cvar.getInstance().find("maxclients").forceSet(4) host.resetClients() first = host.createGameInstance(ChangeMapInfo("testbox", false, false), null) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 8559d248..ea76a775 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt @@ -2,7 +2,6 @@ package org.demoth.cake.hosting import org.demoth.cake.profile.CakeGameProfile import org.demoth.cake.network.ClientNetwork -import org.demoth.cake.network.LegacyClientNetwork import org.demoth.cake.network.TransportClientNetwork import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.transport.LocalSide @@ -10,7 +9,6 @@ import jake2.qcommon.network.transport.LocalSide /** Application-thread boundary between Cake and an optional embedded server. */ interface CakeHost { val clientNetwork: ClientNetwork - get() = LegacyClientNetwork val availability: HostAvailability val state: HostState diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index 45ee4635..366831b4 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -1,6 +1,5 @@ package org.demoth.cake.network -import jake2.qcommon.Defines import jake2.qcommon.network.* import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket @@ -18,20 +17,6 @@ interface ClientNetwork : AutoCloseable { override fun close() = stop() } -/** Compatibility composition until application roots supply their own local link. */ -@Deprecated("Compatibility adapter scheduled for removal; inject TransportClientNetwork with an owned NetworkTransport") -object LegacyClientNetwork : ClientNetwork { - override fun configure(multiplayer: Boolean) = NET.configureClient(multiplayer) - override fun newChannel() = netchan_t() - override fun receive() = NET.receiveNetworkPacket(Defines.NS_CLIENT) - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = - Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, to, command, payload) - override fun stop() { - NET.configureClient(false) - NET.clearLoopbacks() - } -} - /** Owns the supplied transport; connections borrow it through the shared packet adapter. */ class TransportClientNetwork(transport: NetworkTransport) : ClientNetwork { private val packets = PacketNetwork(transport, NetworkRole.CLIENT) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 21ed916e..d51a63f3 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -38,7 +38,7 @@ import jake2.server.save.ServerSaveJsonStore /** Owns client connections, Game routing, protocol and commands for one server Host. */ class ServerHost @JvmOverloads constructor( - private val network: ServerNetwork = LegacyServerNetwork, + private val network: ServerNetwork, private val startupRejection: () -> String? = { null }, ) : AutoCloseable { // region Host state and command dispatch diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index aa386c32..4995d440 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -1,12 +1,12 @@ package jake2.server -import jake2.qcommon.Defines import jake2.qcommon.network.* import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.runtime.PacketNetwork import jake2.qcommon.network.runtime.NetworkRole +import jake2.qcommon.network.transport.LocalSide /** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ServerNetwork : AutoCloseable { @@ -19,23 +19,12 @@ interface ServerNetwork : AutoCloseable { override fun close() = stop() } -/** Compatibility composition until application roots supply their own local link. */ -@Deprecated("Compatibility adapter scheduled for removal; inject TransportServerNetwork with an owned NetworkTransport") -internal object LegacyServerNetwork : ServerNetwork { - override fun configure(multiplayer: Boolean) = NET.configureServer(multiplayer) - override fun newChannel() = netchan_t() - override fun receive() = NET.receiveNetworkPacket(Defines.NS_SERVER) - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, to, command, payload) - override fun stop() { - NET.configureServer(false) - NET.clearLoopback(Defines.NS_SERVER) - } - override fun sleep(millis: Int) = NET.Sleep(millis) -} - /** Owns the supplied transport; connections borrow it through the shared packet adapter. */ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNetwork { + companion object { + @JvmStatic fun standalone() = TransportServerNetwork( + NetworkTransport.standalone(LocalSide.SECOND)) + } private val packets = PacketNetwork(transport, NetworkRole.SERVER) override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) diff --git a/server/src/test/java/jake2/server/AirAccelerationTest.java b/server/src/test/java/jake2/server/AirAccelerationTest.java index f19fd19c..1f78dc38 100644 --- a/server/src/test/java/jake2/server/AirAccelerationTest.java +++ b/server/src/test/java/jake2/server/AirAccelerationTest.java @@ -27,7 +27,7 @@ void onlyDeathmatchEnablesAirAccelerationAndEachInstanceKeepsItsSetting() { try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); var acceleration = cvars.find("sv_airaccelerate", Float.class); var deathmatch = cvars.find("deathmatch", Boolean.class); acceleration.forceSet(2.5f); @@ -57,7 +57,7 @@ void pendingChangesApplyToNewGamesWithoutChangingExistingGames() { try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); cvars.find("deathmatch", Boolean.class).forceSet(true); var acceleration = cvars.find("sv_airaccelerate", Float.class); acceleration.forceSet(1f); @@ -90,7 +90,7 @@ void loadingCapturesRestoredSettingsBeforePublishingMovementConfiguration(@TempD try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); ServerSaveJsonStore.forWriteDir(temp.toString()).writeLatchedCvars("current", List.of( new ServerSaveJsonStore.LatchedCvarSnapshot("deathmatch", "1"), new ServerSaveJsonStore.LatchedCvarSnapshot("sv_airaccelerate", "3.5"))); @@ -113,7 +113,7 @@ void savesCaptureTheOwningGamesConfiguredValueRatherThanTheLatestGlobalValue(@Te try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); var deathmatch = cvars.find("deathmatch", Boolean.class); var acceleration = cvars.find("sv_airaccelerate", Float.class); deathmatch.forceSet(true); diff --git a/server/src/test/java/jake2/server/GameCvarInitializationTest.java b/server/src/test/java/jake2/server/GameCvarInitializationTest.java index a7851fd1..4c8bb810 100644 --- a/server/src/test/java/jake2/server/GameCvarInitializationTest.java +++ b/server/src/test/java/jake2/server/GameCvarInitializationTest.java @@ -28,7 +28,7 @@ void gameModeWritesStayLocalAndRetainLatchSemantics() { cvars.setResult("maxclients", "3.5"); cvars.setResult("timeout", "0.5"); MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); cvar_t maxclients = cvars.find("maxclients", Integer.class); cvar_t deathmatch = imports.findCvar("deathmatch", Boolean.class); @@ -65,7 +65,7 @@ void successorCopiesSettingsAndAppliesPendingWithoutChangingTheSource() { try { cvars.setResult("skill", "2"); MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = new GameImportsImpl(server, new ChangeMapInfo("first.pcx", false, false)); var skill = first.registerCvar("skill", new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)), 0, CVAR_LATCH, null); var gravity = first.registerCvar("sv_gravity", CvarTypes.FLOAT, 800f, 0, null); @@ -101,7 +101,7 @@ void typedDeclarationsRejectInvalidEarlyInputAndRemainIndependent() { cvars.setResult("skill", "4"); cvars.setResult("sv_gravity", "600.5"); MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl first = new GameImportsImpl(server, new ChangeMapInfo("first.pcx", false, false)); GameImportsImpl second = new GameImportsImpl(server, new ChangeMapInfo("second.pcx", false, false)); var skillType = new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)); @@ -135,7 +135,7 @@ void explicitLocalRegistrationShadowsGlobalWithoutChangingIt() { try { MainCommon.registerCvars(false); var globalSetting = cvars.register("test_setting", CvarTypes.STRING, "client-secret", CVAR_USERINFO | CVAR_ARCHIVE); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); assertSame(globalSetting, imports.findCvar("test_setting", String.class)); var localSetting = imports.registerCvar("test_setting", CvarTypes.STRING, "", CVAR_USERINFO, null); @@ -162,7 +162,7 @@ void savedInputsOverrideTheSourceAndInitializeLocalDeclarations(@TempDir Path te EngineWriteRoot.setRoot(temp); try { MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); GameImportsImpl other = new GameImportsImpl(server, new ChangeMapInfo("other.pcx", false, false)); cvar_t otherSkill = other.registerCvar("skill", new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)), 1, CVAR_LATCH, null); ServerSaveJsonStore.forWriteDir(temp.toString()).writeLatchedCvars("current", List.of( @@ -193,7 +193,7 @@ void gameDeclarationsConsumeEarlyInputsWithoutSharingTheirLiveState() { cvars.setResult("maxspectators", "8"); cvars.setResult("spectator_password", "secret"); MainCommon.registerCvars(false); - ServerHost server = new ServerHost(); + ServerHost server = new ServerHost(TransportServerNetwork.standalone()); // A picture transition constructs real game imports without loading a BSP or opening sockets. GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); diff --git a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt index 3a6f3ebc..094171b7 100644 --- a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt +++ b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt @@ -28,7 +28,7 @@ class GameModeCvarsTest { EngineWriteRoot.setRoot(root) cvars.clear() MainCommon.registerCvars(false) - host = ServerHost() + host = ServerHost(TransportServerNetwork.standalone()) } @AfterEach diff --git a/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt b/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt index 8793b6d9..6876fd53 100644 --- a/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt +++ b/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt @@ -25,7 +25,7 @@ class LocalAirAccelerationTest { Globals.server_state = ServerStates.SS_DEAD cvars.clear() MainCommon.registerCvars(false) - host = ServerHost() + host = ServerHost(TransportServerNetwork.standalone()) cvars.find("deathmatch").forceSet(true) cvars.find("sv_airaccelerate").forceSet(2f) } From fdd7edeff0ead4bfa0975905240223ba55b8b32c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 12:28:44 +0100 Subject: [PATCH 28/57] Bound client and server packet polling work per pass --- .../src/main/kotlin/org/demoth/cake/Cake.kt | 12 ++--- .../org/demoth/cake/network/ClientNetwork.kt | 4 +- .../qcommon/network/runtime/PacketNetwork.kt | 14 +++++- .../network/runtime/PacketNetworkTest.kt | 45 +++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 8 +--- .../main/kotlin/jake2/server/ServerNetwork.kt | 4 +- .../kotlin/jake2/server/ServerNetworkTest.kt | 35 +++++++++++++-- 7 files changed, 100 insertions(+), 22 deletions(-) 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 0fe4ba33..7a736bd0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1099,19 +1099,15 @@ class Cake( } private fun CL_ReadPackets() { - while (true) { - val networkPacket = network.receive() - - if (networkPacket == null) break - + network.poll { networkPacket -> if (networkPacket.isConnectionless) { CL_ConnectionlessPacket(networkPacket) - continue + return@poll } if (networkState == CONNECTING || networkState == DISCONNECTED) { // dump it if not connected - continue + return@poll } // // packet from server @@ -1119,7 +1115,7 @@ class Cake( if (networkPacket.from.type != netchan.remote_address.type || !networkPacket.from.compareIp(netchan.remote_address)) { Com.Warn(networkPacket.from.toString() + ": sequenced packet without connection\n") - continue + return@poll } if (netchan.accept(networkPacket)) { diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index 366831b4..52142c26 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -11,7 +11,7 @@ import jake2.qcommon.network.runtime.NetworkRole interface ClientNetwork : AutoCloseable { fun configure(multiplayer: Boolean): Boolean fun newChannel(): netchan_t - fun receive(): NetworkPacket? + fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) fun stop() override fun close() = stop() @@ -22,7 +22,7 @@ class TransportClientNetwork(transport: NetworkTransport) : ClientNetwork { private val packets = PacketNetwork(transport, NetworkRole.CLIENT) override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) - override fun receive() = packets.receive() + override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = packets.sendConnectionless(to, command, payload) override fun stop() = packets.stop() diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index 787fd779..2e5c767e 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -24,6 +24,11 @@ class PacketNetwork( private val transport: NetworkTransport, private val role: NetworkRole, ) : AutoCloseable { + companion object { + /** Maximum datagrams consumed per receive pass, including malformed and dropped traffic. */ + const val DEFAULT_POLL_BUDGET = 256 + } + private var closed = false private val label = if (role == NetworkRole.CLIENT) "Client" else "Server" private val direction = if (role == NetworkRole.CLIENT) PacketDirection.FROM_SERVER else PacketDirection.FROM_CLIENT @@ -66,7 +71,14 @@ class PacketNetwork( /** Compatibility nullable view; explicit transport/codec outcomes remain below this adapter. */ fun receive(): NetworkPacket? { - val received = transport.receive() + return decode(transport.receive()) + } + + /** Bound transport work, rather than only successfully decoded packets. */ + fun poll(budget: Int = DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int = + transport.poll(budget) { received -> decode(received)?.let(consume) } + + private fun decode(received: ReceiveResult): NetworkPacket? { if (received is ReceiveResult.Failed) Com.Println("$label receive error: ${received.cause}") if (received !is ReceiveResult.Received) return null val datagram = received.datagram diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt index a591f454..b68a70d2 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -8,9 +8,54 @@ import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.transport.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import java.net.DatagramSocket class PacketNetworkTest { + @ParameterizedTest + @EnumSource(NetworkRole::class) + fun `malformed traffic consumes the default budget and remaining packets wait for next pass`(role: NetworkRole) { + val budget = PacketNetwork.DEFAULT_POLL_BUDGET + LoopbackLink(capacity = budget + 1).use { link -> + val transport = NetworkTransport(link.first, link.second.localAddress) + PacketNetwork(transport, role).use { network -> + repeat(budget) { link.second.send(link.first.localAddress, PacketBytes.copyOf(byteArrayOf(1))) } + val ping = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray()) + link.second.send(link.first.localAddress, ping) + assertEquals(budget, network.poll { fail("Malformed datagrams must not reach consumers") }) + var delivered = 0 + assertEquals(1, network.poll { + delivered++ + assertEquals("ping", it.connectionlessMessage) + }) + assertEquals(1, delivered) + assertEquals(0, network.poll { fail("Queue should be empty") }) + } + } + } + + @Test + fun `poll continues past malformed packets within budget and bounds self-replenishing traffic`() { + LoopbackLink().use { link -> + val transport = NetworkTransport(link.first, link.second.localAddress) + PacketNetwork(transport, NetworkRole.CLIENT).use { network -> + val ping = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray()) + link.second.send(link.first.localAddress, PacketBytes.copyOf(byteArrayOf(1))) + link.second.send(link.first.localAddress, ping) + var delivered = 0 + assertEquals(5, network.poll(5) { + delivered++ + link.second.send(link.first.localAddress, ping) + }) + assertEquals(4, delivered) + assertEquals(0, network.poll(0) { fail("Zero budget must not consume traffic") }) + assertThrows(IllegalArgumentException::class.java) { network.poll(-1) {} } + assertEquals("ping", network.receive()!!.connectionlessMessage) + } + } + } + @Test fun `shared packet adapter preserves direction and Latin-1 packet size boundaries`() { LoopbackLink().use { link -> diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index d51a63f3..ace75eef 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1229,14 +1229,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) * Reads packets from the network or loopback. */ fun readPackets() { - while (true) { - val networkPacket = network.receive() - - if (networkPacket == null) break - + network.poll { networkPacket -> if (networkPacket.isConnectionless) { handleConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) - continue + return@poll } // Match incoming packet with the existing client. diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index 4995d440..ea766438 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -12,7 +12,7 @@ import jake2.qcommon.network.transport.LocalSide interface ServerNetwork : AutoCloseable { fun configure(multiplayer: Boolean): Boolean fun newChannel(): netchan_t - fun receive(): NetworkPacket? + fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) fun stop() fun sleep(millis: Int) @@ -28,7 +28,7 @@ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNe private val packets = PacketNetwork(transport, NetworkRole.SERVER) override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) - override fun receive() = packets.receive() + override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = packets.sendConnectionless(to, command, payload) override fun stop() = packets.stop() diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt index 1ff922e2..7e82eb6a 100644 --- a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -5,20 +5,29 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan import jake2.qcommon.network.transport.* import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.runtime.PacketNetwork import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class ServerNetworkTest { - private class Peer : AutoCloseable { - private val link = LoopbackLink() + private class Peer(capacity: Int = 4) : AutoCloseable { + private val link = LoopbackLink(capacity) val client = NetworkTransport(link.first, link.second.localAddress) val server = NetworkTransport(link.second, link.first.localAddress) val network = TransportServerNetwork(server) val host = ServerHost(network) - fun ping() { + fun queuePing() { client.send(client.localPeer, PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray())) + } + fun ping() { + queuePing() host.readPackets() } + fun countReplies(): Int { + var count = 0 + while (client.receive() is ReceiveResult.Received) count++ + return count + } fun reply(): String { val received = client.receive() as ReceiveResult.Received return received.datagram.payload.toByteArray().drop(4).toByteArray().toString(Charsets.ISO_8859_1) @@ -29,6 +38,26 @@ class ServerNetworkTest { } } + @Test + fun `Host packet pass bounds malformed and valid traffic and resumes next pass`() { + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + try { + val budget = PacketNetwork.DEFAULT_POLL_BUDGET + Peer(capacity = budget + 1).use { peer -> + peer.client.send(peer.client.localPeer, PacketBytes.copyOf(byteArrayOf(1))) + repeat(budget) { peer.queuePing() } + peer.host.readPackets() + assertEquals(budget - 1, peer.countReplies()) + peer.host.readPackets() + assertEquals(1, peer.countReplies()) + } + } finally { + Cvar.getInstance().clear() + } + } + @Test fun `hosts reply on their injected links and closing one leaves the other running`() { Cvar.getInstance().clear() From d6f40fb466e04cc3312941d133b7e94f13706bc5 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 12:43:21 +0100 Subject: [PATCH 29/57] Extract client handshake retry and target validation controller --- .../src/main/kotlin/org/demoth/cake/Cake.kt | 72 ++------- .../demoth/cake/network/ClientHandshake.kt | 88 +++++++++++ .../cake/network/ClientHandshakeTest.kt | 138 ++++++++++++++++++ 3 files changed, 239 insertions(+), 59 deletions(-) create mode 100644 cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt create mode 100644 cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt 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 7a736bd0..cc32ce24 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -23,6 +23,7 @@ import jake2.qcommon.exec.Cmd.getArguments import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import org.demoth.cake.network.ClientNetwork +import org.demoth.cake.network.ClientHandshake import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand @@ -80,7 +81,6 @@ class Cake( private val network: ClientNetwork = host.clientNetwork, ) : KtxApplicationAdapter, KtxInputAdapter { companion object { - private const val CONNECT_RETRY_TIMEOUT_SECONDS = 1f private const val CONNECTED_KEEPALIVE_TIMEOUT_MS = 1000 private const val MAX_CLIENT_COMMAND_CATCHUP_STEPS = 4 private const val PROFILE_BACKGROUND_PATH = "pics/conback.pcx" @@ -130,11 +130,8 @@ class Cake( // Connection and download protocol state. private var networkState = DISCONNECTED - // Retain the last endpoint for manual reconnect; handshake replies also require CONNECTING. - private var connectionTarget: netadr_t? = null - private var localConnection = false - private var challenge = 0 - private var reconnectTimeout = 0f // fire first connect attempt immediately + private val handshake = ClientHandshake(network, ::connectPayload) + private val localConnection get() = handshake.isLocal private val netchan = network.newChannel() private val downloads = CakeDownloadSession(sendReliable = { command -> @@ -290,6 +287,7 @@ class Cake( */ Cmd.AddCommand("changing", "(internal) Enter map-change transition state") { networkState = CONNECTED + handshake.pause() // todo: indicate somehow about the map changing (loading screen or spinner) // SCR.BeginLoadingPlaque(); Com.Printf("\nChanging map...\n") @@ -306,7 +304,7 @@ class Cake( netchan.reliablePending.add(StringCmdMessage(StringCmdMessage.NEW)) } else { - val target = connectionTarget ?: return@AddCommand + val target = handshake.lastTarget ?: return@AddCommand val local = target.type == NetAddrType.NA_LOOPBACK if (local && host.state != HostState.RUNNING) { Com.Warn("No local server is running.\n") @@ -496,16 +494,11 @@ class Cake( } private fun beginConnection(target: netadr_t, local: Boolean) { - if (!local && !network.configure(true)) { + if (!handshake.begin(target, local)) { Com.Warn("Could not open the client UDP socket\n") return } - if (local) network.configure(false) Com.Printf("Connecting to ${if (local) "local server" else target}...\n") - connectionTarget = target - localConnection = local - challenge = 0 - reconnectTimeout = 0f networkState = CONNECTING } @@ -530,9 +523,7 @@ class Cake( clearTransitionBackdrop() netchan.clear() networkState = DISCONNECTED - localConnection = false - challenge = 0 - reconnectTimeout = 0f + handshake.reset() clientCommandExtraMs = 0f downloads.reset() networkDebugSampler.clear() @@ -695,7 +686,7 @@ class Cake( } game3dScreen?.updateLocalInput(deltaSeconds) runClientCommandSteps(deltaSeconds) - CheckForResend(deltaSeconds) + handshake.tick(deltaSeconds) if (!drawable) return if (uiViewport.setReferenceHeight(uiSize.typedValue)) { @@ -1059,43 +1050,12 @@ class Cake( deferredConfigUnloadScreen = null } - /** - * CheckForResend - * - * Resend a connect message if the last one has timed out. - */ - private fun CheckForResend(deltaSeconds: Float) { - // resend if we haven't gotten a reply yet - if (networkState != CONNECTING) - return - - val adr = connectionTarget ?: return - if (reconnectTimeout <= 0f) { - if (localConnection) { - // The server already trusts in-process loopback; use its normal connect path. - SendConnectPacket() - } else { - network.sendConnectionless(adr, ConnectionlessCommand.getchallenge, "\n") - } - reconnectTimeout = CONNECT_RETRY_TIMEOUT_SECONDS - } else { - reconnectTimeout -= deltaSeconds - } - } - // The connect payload already includes current userinfo, so consume the legacy dirty flag. - // Keep this suppression until the client/cvar sync replacement noted in Globals exists. @Suppress("DEPRECATION") - private fun SendConnectPacket() { - val adr = connectionTarget ?: return - + private fun connectPayload(challenge: Int): String { val port = Cvar.getInstance().find("qport").typedValue Globals.userinfo_modified = false - - network.sendConnectionless(adr, - ConnectionlessCommand.connect, - " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" - ) + return " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" } private fun CL_ReadPackets() { @@ -1153,8 +1113,7 @@ class Cake( Com.Printf("ReconnectMessage: Reconnecting..\n") beginMapTransitionRetainingConfigAssets() networkState = CONNECTING - // CheckForResend() will fire immediately - reconnectTimeout = 0f + handshake.restart() } is StuffTextMessage -> { @@ -1347,18 +1306,13 @@ class Cake( val args = Cmd.TokenizeString(packet.connectionlessMessage, false) val c = args.firstOrNull() ?: return val cmd = ConnectionlessCommand.fromString(c) - if (cmd == ConnectionlessCommand.challenge || cmd == ConnectionlessCommand.client_connect) { - val target = connectionTarget ?: return - if (networkState != CONNECTING || packet.from.type != target.type || !packet.from.compareIp(target)) return - } - when (cmd) { ConnectionlessCommand.challenge -> { - challenge = args.getOrNull(1)?.toIntOrNull() ?: return - SendConnectPacket() + handshake.receiveChallenge(packet.from, args.getOrNull(1)) } ConnectionlessCommand.client_connect -> { + if (!handshake.acceptConnection(packet.from)) return networkState = CONNECTED netchan.setup(NS_CLIENT, packet.from, Cvar.getInstance().find("qport").typedValue) netchan.reliablePending.add(StringCmdMessage(StringCmdMessage.NEW)) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt new file mode 100644 index 00000000..c0920e26 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt @@ -0,0 +1,88 @@ +package org.demoth.cake.network + +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.transport.IpAddress + +/** Game-thread handshake controller. Time and current connect payload are supplied by the caller. */ +class ClientHandshake( + private val network: ClientNetwork, + private val connectPayload: (challenge: Int) -> String, +) { + private data class Target(val type: NetAddrType, val ip: IpAddress, val port: Int) { + fun toLegacy() = netadr_t().also { it.type = type; it.ip = ip.toByteArray(); it.port = port } + fun matches(from: netadr_t) = type == from.type && port == from.port && ip == IpAddress.fromBytes(from.ip) + companion object { + fun copyOf(address: netadr_t) = Target(address.type, IpAddress.fromBytes(address.ip), address.port) + } + } + + private var target: Target? = null + /** Owned snapshot, retained after reset for the manual reconnect command. */ + val lastTarget: netadr_t? get() = target?.toLegacy() + var isLocal = false + private set + private var pending = false + private var challenge = 0 + private var retryRemainingSeconds = 0f + + /** A failed bind leaves the current handshake untouched. */ + fun begin(address: netadr_t, local: Boolean): Boolean { + if (!network.configure(!local)) return false + target = Target.copyOf(address) + isLocal = local + challenge = 0 + restart() + return true + } + + /** Server reconnect keeps the target/challenge and fires on the next frame. */ + fun restart() { + pending = target != null + retryRemainingSeconds = 0f + } + + /** Map-change commands can retire a pending handshake without resetting the connection. */ + fun pause() { pending = false } + + fun reset() { + pending = false + isLocal = false + challenge = 0 + retryRemainingSeconds = 0f + } + + /** Preserve frame-based retry timing: a timer reaching zero sends on the following tick. */ + fun tick(deltaSeconds: Float) { + if (!pending) return + val peer = target ?: return + if (retryRemainingSeconds <= 0f) { + if (isLocal) sendConnect(peer) + else network.sendConnectionless(peer.toLegacy(), ConnectionlessCommand.getchallenge, "\n") + retryRemainingSeconds = 1f + } else { + retryRemainingSeconds -= deltaSeconds + } + } + + fun receiveChallenge(from: netadr_t, value: String?): Boolean { + val peer = expected(from) ?: return false + val parsed = value?.toIntOrNull() ?: return false + challenge = parsed + sendConnect(peer) + return true + } + + fun acceptConnection(from: netadr_t): Boolean { + if (expected(from) == null) return false + pending = false + return true + } + + private fun expected(from: netadr_t): Target? = target?.takeIf { pending && it.matches(from) } + + private fun sendConnect(peer: Target) { + network.sendConnectionless(peer.toLegacy(), ConnectionlessCommand.connect, connectPayload(challenge)) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt new file mode 100644 index 00000000..15d7fac7 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt @@ -0,0 +1,138 @@ +package org.demoth.cake.network + +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.messages.NetworkPacket +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ClientHandshakeTest { + private data class Sent(val ip: List, val port: Int, val command: ConnectionlessCommand, val payload: String) + private class Network : ClientNetwork { + val sent = mutableListOf() + val binds = mutableListOf() + var bindSucceeds = true + override fun configure(multiplayer: Boolean): Boolean { + binds += multiplayer + return bindSucceeds + } + override fun newChannel(): netchan_t = error("Handshake must not create protocol channels") + override fun poll(budget: Int, consume: (NetworkPacket) -> Unit): Int = error("Caller owns polling") + override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { + sent += Sent(to.ip.toList(), to.port, command, payload) + // Deliberately hostile legacy consumer: it cannot mutate the retained target. + to.ip.fill(0) + to.port = 0 + } + override fun stop() = error("Handshake must not stop its owner's transport") + } + private fun remote(port: Int = 27910) = netadr_t().apply { + type = NetAddrType.NA_IP + ip = byteArrayOf(127, 0, 0, 1) + this.port = port + } + + @Test + fun `remote retry uses frame timing and fresh connect payload then stops after acceptance`() { + val network = Network() + var userinfo = "first" + val handshake = ClientHandshake(network) { "$it $userinfo" } + val peer = remote() + assertTrue(handshake.begin(peer, false)) + assertEquals(listOf(true), network.binds) + handshake.tick(0f) + assertEquals(ConnectionlessCommand.getchallenge, network.sent.single().command) + assertEquals("\n", network.sent.single().payload) + handshake.tick(1f) + assertEquals(1, network.sent.size) + handshake.tick(0f) + assertEquals(2, network.sent.size) + assertTrue(handshake.receiveChallenge(peer, "123")) + assertEquals("123 first", network.sent.last().payload) + userinfo = "updated" + assertTrue(handshake.receiveChallenge(peer, "456")) + assertEquals("456 updated", network.sent.last().payload) + assertTrue(handshake.acceptConnection(peer)) + val acceptedCount = network.sent.size + handshake.tick(100f) + assertFalse(handshake.receiveChallenge(peer, "789")) + assertFalse(handshake.acceptConnection(peer)) + assertEquals(acceptedCount, network.sent.size) + } + + @Test + fun `challenge and acceptance require a pending handshake and exact endpoint`() { + val network = Network() + val handshake = ClientHandshake(network) { it.toString() } + assertFalse(handshake.acceptConnection(remote())) + assertTrue(handshake.begin(remote(), false)) + val wrongPeers = listOf(remote(27911), remote().apply { ip[3] = 2 }, remote().apply { type = NetAddrType.NA_LOOPBACK }) + for (peer in wrongPeers) { + assertFalse(handshake.receiveChallenge(peer, "42")) + assertFalse(handshake.acceptConnection(peer)) + } + for (value in listOf(null, "", "nonsense", "2147483648")) + assertFalse(handshake.receiveChallenge(remote(), value)) + assertTrue(network.sent.isEmpty()) + assertTrue(handshake.receiveChallenge(remote(), "-123")) + handshake.reset() + assertFalse(handshake.receiveChallenge(remote(), "42")) + assertFalse(handshake.acceptConnection(remote())) + } + + @Test + fun `local attachment bypasses challenge and reset retains an isolated target for reconnect`() { + val network = Network() + val handshake = ClientHandshake(network) { it.toString() } + val address = netadr_t() + assertTrue(handshake.begin(address, true)) + address.ip[0] = 99 + handshake.lastTarget!!.ip[1] = 99 + assertTrue(handshake.isLocal) + assertEquals(listOf(false), network.binds) + handshake.tick(0f) + assertEquals(ConnectionlessCommand.connect, network.sent.single().command) + assertEquals("0", network.sent.single().payload) + assertEquals(netadr_t().ip.toList(), network.sent.single().ip) + assertTrue(handshake.acceptConnection(netadr_t())) + handshake.reset() + assertFalse(handshake.isLocal) + val sent = network.sent.size + handshake.tick(10f) + assertEquals(sent, network.sent.size) + assertTrue(handshake.begin(handshake.lastTarget!!, true)) + handshake.tick(0f) + assertEquals(ConnectionlessCommand.connect, network.sent.last().command) + } + + @Test + fun `server restart retries immediately and map changing pauses pending replies`() { + val network = Network() + val handshake = ClientHandshake(network) { it.toString() } + handshake.begin(remote(), false) + assertTrue(handshake.acceptConnection(remote())) + handshake.restart() + handshake.tick(0f) + assertEquals(ConnectionlessCommand.getchallenge, network.sent.single().command) + handshake.pause() + assertFalse(handshake.receiveChallenge(remote(), "42")) + assertFalse(handshake.acceptConnection(remote())) + handshake.tick(100f) + assertEquals(1, network.sent.size) + } + + @Test + fun `failed bind preserves active target and retry state`() { + val network = Network() + val handshake = ClientHandshake(network) { it.toString() } + handshake.begin(remote(), false) + network.bindSucceeds = false + assertFalse(handshake.begin(remote(12345), false)) + assertEquals(27910, handshake.lastTarget!!.port) + handshake.tick(0f) + assertEquals(27910, network.sent.single().port) + assertTrue(handshake.acceptConnection(remote())) + } +} From 66a5008163ab8fc0cd43b97b0f6efaf494d89e9f Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 12:57:41 +0100 Subject: [PATCH 30/57] Extract server connection admission and packet routing --- .../jake2/server/HostClientAccountingTest.kt | 53 ++++--- .../main/java/jake2/server/challenge_t.java | 33 ---- .../kotlin/jake2/server/ServerConnections.kt | 94 ++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 145 ++++-------------- .../jake2/server/ServerConnectionsTest.kt | 104 +++++++++++++ 5 files changed, 256 insertions(+), 173 deletions(-) delete mode 100644 server/src/main/java/jake2/server/challenge_t.java create mode 100644 server/src/main/kotlin/jake2/server/ServerConnections.kt create mode 100644 server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 988fea97..9d022ba6 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -384,26 +384,37 @@ class HostClientAccountingTest { @Test fun `remote reconnect uses Host acceptance time across Game frame changes`() { - // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. - val address = netadr_t().apply { port = 12345 } - host.handleConnectionlessPacket(address, "getchallenge") - val reply = network.receiveLocal(Defines.NS_CLIENT)!!.connectionlessMessage - val challenge = reply.trim().substringAfterLast(' ') - val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" - first.frameNum = 9000 - host.handleConnectionlessPacket(address, connect) - val client = host.clients[0] - assertEquals(ClientStates.CS_CONNECTED, client.state) - assertEquals(100L, client.lastconnect) - drainPackets() - cvars.find("sv_reconnect_limit").forceSet(0.5f) - host.update(499) - first.frameNum = 50000 - host.handleConnectionlessPacket(address, connect) - assertEquals(100L, client.lastconnect, "Reconnect before the boundary must be rejected") - host.update(1) - host.handleConnectionlessPacket(address, connect) - assertEquals(600L, client.lastconnect) - assertEquals(600L, client.lastmessage) + cvars.find("port").forceSet(0) + assertTrue(network.server.configure(true)) + val loopback = java.net.InetAddress.getByName("127.0.0.1") + java.net.DatagramSocket(0, loopback).use { receiver -> + receiver.soTimeout = 2000 + val address = netadr_t().apply { + type = NetAddrType.NA_IP + ip = loopback.address + port = receiver.localPort + } + host.handleConnectionlessPacket(address, "getchallenge") + val response = java.net.DatagramPacket(ByteArray(2048), 2048) + receiver.receive(response) + val reply = String(response.data, 4, response.length - 4) + val challenge = reply.trim().substringAfterLast(' ') + val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" + first.frameNum = 9000 + host.handleConnectionlessPacket(address, connect) + val client = host.clients[0] + assertEquals(ClientStates.CS_CONNECTED, client.state) + assertEquals(100L, client.lastconnect) + drainPackets() + cvars.find("sv_reconnect_limit").forceSet(0.5f) + host.update(499) + first.frameNum = 50000 + host.handleConnectionlessPacket(address, connect) + assertEquals(100L, client.lastconnect, "Reconnect before the boundary must be rejected") + host.update(1) + host.handleConnectionlessPacket(address, connect) + assertEquals(600L, client.lastconnect) + assertEquals(600L, client.lastmessage) + } } } diff --git a/server/src/main/java/jake2/server/challenge_t.java b/server/src/main/java/jake2/server/challenge_t.java deleted file mode 100644 index 70c05c0e..00000000 --- a/server/src/main/java/jake2/server/challenge_t.java +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright (C) 1997-2001 Id Software, Inc. - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - -*/ - -// Created on 14.01.2004 by RST. - -package jake2.server; - -import jake2.qcommon.network.netadr_t; - -/** One entry in the Host's bounded connection-challenge cache; see ServerHost.getChallenge. */ -public class challenge_t { - netadr_t adr = new netadr_t(); - int challenge; - // Host-local eviction order, not a timestamp or wire value; zero denotes an unused entry. - long issuedOrder; -} diff --git a/server/src/main/kotlin/jake2/server/ServerConnections.kt b/server/src/main/kotlin/jake2/server/ServerConnections.kt new file mode 100644 index 00000000..9660b196 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerConnections.kt @@ -0,0 +1,94 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.transport.IpAddress +import jake2.qcommon.util.Lib + +/** Game-thread protocol admission and routing; gameplay admission remains with the Host. */ +class ServerConnections( + private val challengeCapacity: Int = Defines.MAX_CHALLENGES, + private val nextToken: () -> Int = { Lib.rand().toInt() }, +) { + private data class PeerKey(val type: NetAddrType, val ip: IpAddress?) { + companion object { + fun of(address: netadr_t) = PeerKey(address.type, + if (address.type == NetAddrType.NA_LOOPBACK) null else IpAddress.fromBytes(address.ip)) + } + } + + enum class ChallengeValidation { ACCEPTED, BAD, MISSING } + sealed interface Admission { + data class Slot(val index: Int, val reconnect: Boolean) : Admission + data object TooSoon : Admission + data object Full : Admission + } + data class Routed(val client: ClientNetworkInfo, val portChanged: Boolean) + + init { require(challengeCapacity > 0) } + + // Insertion order is issuance order, independent of engine/Game clocks. Reuse never refreshes it. + private val challenges = linkedMapOf() + + /** + * Issue or reuse the token for the connectionless getchallenge -> challenge -> connect handshake. + * A remote client echoes the token in its connect request; [validateChallenge] checks it against + * the cached source IP (UDP port is ignored). Local connections bypass that check. + * + * This makes blind source-IP spoofing harder: the requester normally needs to receive the + * reply before it can connect. The legacy 15-bit random token is not authentication or a + * general denial-of-service defence; it can be guessed and does not prevent request floods. + * + * The bounded cache has no time-based expiry. Repeated requests reuse the token without + * refreshing its position. New addresses take an unused entry or evict the earliest-issued + * entry. Map insertion order tracks issuance even within one clock tick and across global + * clock resets/wrap. Session stop calls [reset] to clear the cache; this order is never sent. + */ + fun issueChallenge(from: netadr_t): Int { + val key = PeerKey.of(from) + challenges[key]?.let { return it } + if (challenges.size == challengeCapacity) challenges.remove(challenges.keys.first()) + return (nextToken() and 0x7fff).also { challenges[key] = it } + } + + fun validateChallenge(from: netadr_t, token: Int): ChallengeValidation { + if (from.type == NetAddrType.NA_LOOPBACK) return ChallengeValidation.ACCEPTED + val expected = challenges[PeerKey.of(from)] ?: return ChallengeValidation.MISSING + return if (expected == token) ChallengeValidation.ACCEPTED else ChallengeValidation.BAD + } + + fun selectSlot( + clients: List, from: netadr_t, qport: Int, + nowMillis: Long, reconnectDelayMillis: Float, + ): Admission { + val key = PeerKey.of(from) + for ((index, client) in clients.withIndex()) { + if (client.state == ClientStates.CS_FREE) continue + if (PeerKey.of(client.netchan.remote_address) != key) continue + if (client.netchan.qport != qport && client.netchan.remote_address.port != from.port) continue + if (from.type != NetAddrType.NA_LOOPBACK && nowMillis - client.lastconnect < reconnectDelayMillis) + return Admission.TooSoon + return Admission.Slot(index, reconnect = true) + } + val free = clients.indexOfFirst { it.state == ClientStates.CS_FREE } + return if (free < 0) Admission.Full else Admission.Slot(free, reconnect = false) + } + + /** Match qport/base address, then accept sequence state before committing a NAT port change. */ + fun route(packet: NetworkPacket, clients: List): Routed? { + if (packet.isConnectionless) return null + val key = PeerKey.of(packet.from) + val client = clients.firstOrNull { + it.state != ClientStates.CS_FREE && it.netchan.qport == packet.qport && + PeerKey.of(it.netchan.remote_address) == key + } ?: return null + if (!client.netchan.accept(packet)) return null + val changed = client.netchan.remote_address.port != packet.from.port + if (changed) client.netchan.remote_address.port = packet.from.port + return Routed(client, changed) + } + + fun reset() = challenges.clear() +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index ace75eef..df7d5a35 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -51,8 +51,7 @@ class ServerHost @JvmOverloads constructor( @JvmField val games: MutableMap = HashMap() - private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } - private var challengeOrder = 0L + private val connections = ServerConnections() var clients: MutableList = mutableListOf() private set @@ -320,8 +319,7 @@ class ServerHost @JvmOverloads constructor( nextFrameTime = 100 paused.forceSet(false) Cvar.getInstance().find("mapname").forceSet("") - for (i in challenges.indices) challenges[i] = challenge_t() - challengeOrder = 0 + connections.reset() // Ordinary queued commands may start the next session (killserver; gamemap ...). Cbuf.ClearDeferred() network.stop() @@ -971,47 +969,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) return info } - /** - * Issue or reuse the token for the connectionless getchallenge -> challenge -> connect handshake. - * A remote client echoes the token in its connect request; [connectClient] checks it against - * the cached source IP (UDP port is ignored). Local connections bypass that check. - * - * This makes blind source-IP spoofing harder: the requester normally needs to receive the - * reply before it can connect. The legacy 15-bit random token is not authentication or a - * general denial-of-service defence; it can be guessed and does not prevent request floods. - * - * The bounded Host cache has no time-based expiry. Repeated requests reuse the token without - * refreshing its position. New addresses take an unused entry or evict the earliest-issued - * entry. A Host-owned counter orders issuance even within one clock tick and across global - * clock resets/wrap. Session stop clears the cache and counter; this order is never sent. - */ - private fun getChallenge(from: netadr_t): Int { - var oldest = 0 - var oldestOrder = Long.MAX_VALUE - - var i: Int - i = 0 - while (i < Defines.MAX_CHALLENGES) { - if (from.CompareBaseAdr(challenges[i].adr)) break - if (challenges[i].issuedOrder < oldestOrder) { - oldestOrder = challenges[i].issuedOrder - oldest = i - } - i++ - } - - if (i == Defines.MAX_CHALLENGES) { - // Unused entries have order zero and are selected before issued entries. - challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff - challenges[oldest].adr = from - // Eviction needs issuance order, not wall time or Game simulation time. - challenges[oldest].issuedOrder = ++challengeOrder - i = oldest - } - - return challenges[i].challenge - } - /** Validate a direct connect request, including its remote-address challenge, before admission. */ private fun connectClient(args: List, adr: netadr_t) { Com.DPrintf("SVC_DirectConnect ()\n") @@ -1033,66 +990,29 @@ Resets server to host a given map, moves all clients to it (via reconnect) // force the IP key/value pair so the game can filter based on ip userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) - // Match the source IP and echoed token before allocating/reusing a client slot. - var j: Int - if (!adr.IsLocalAddress()) { - j = 0 - while (j < Defines.MAX_CHALLENGES) { - if (adr.CompareBaseAdr(challenges[j].adr)) { - if (challenge == challenges[j].challenge) break // good - - network.sendConnectionless(adr, - ConnectionlessCommand.print, - "\nBad challenge.\n" - ) - return - } - j++ - } - if (j == Defines.MAX_CHALLENGES) { - network.sendConnectionless(adr, - ConnectionlessCommand.print, - "\nNo challenge for address.\n" - ) + when (connections.validateChallenge(adr, challenge)) { + ServerConnections.ChallengeValidation.BAD -> { + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nBad challenge.\n") return } - } - - // if there is already a slot for this ip, reuse it - var i = 0 - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) continue - if (adr.CompareBaseAdr(cl.netchan.remote_address) - && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) - ) { - if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (reconnectLimit.typedValue * 1000)) { - Com.DPrintf("$adr:reconnect rejected : too soon\n") - return - } - Com.Printf("$adr:reconnect\n") - - initializeClientConnection(i, challenge, userinfo, adr, qport) + ServerConnections.ChallengeValidation.MISSING -> { + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nNo challenge for address.\n") return } - i++ + ServerConnections.ChallengeValidation.ACCEPTED -> Unit } - // find a client slot - var index = -1 - i = 0 - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) { - index = i - break + when (val admission = connections.selectSlot(clients, adr, qport, realtime, reconnectLimit.typedValue * 1000)) { + ServerConnections.Admission.TooSoon -> Com.DPrintf("$adr:reconnect rejected : too soon\n") + ServerConnections.Admission.Full -> { + network.sendConnectionless(adr, ConnectionlessCommand.print, "\nServer is full.\n") + Com.DPrintf("Rejected a connection.\n") + } + is ServerConnections.Admission.Slot -> { + if (admission.reconnect) Com.Printf("$adr:reconnect\n") + initializeClientConnection(admission.index, challenge, userinfo, adr, qport) } - i++ - } - if (index == -1) { - network.sendConnectionless(adr, ConnectionlessCommand.print, "\nServer is full.\n") - Com.DPrintf("Rejected a connection.\n") - return } - initializeClientConnection(index, challenge, userinfo, adr, qport) } /** @@ -1184,7 +1104,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } ConnectionlessCommand.getchallenge -> { - val challenge = getChallenge(from) + val challenge = connections.issueChallenge(from) network.sendConnectionless(from, ConnectionlessCommand.challenge, " $challenge" @@ -1235,27 +1155,14 @@ Resets server to host a given map, moves all clients to it (via reconnect) return@poll } - // Match incoming packet with the existing client. - // todo: get client by address (hashmap?) - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) continue - if (!networkPacket.from.CompareBaseAdr(cl.netchan.remote_address)) continue - if (cl.netchan.qport != networkPacket.qport) continue - if (cl.netchan.remote_address.port != networkPacket.from.port) { - Com.Printf("SV_ReadPackets: fixing up a translated port\n") - cl.netchan.remote_address.port = networkPacket.from.port - } - - if (cl.netchan.accept(networkPacket)) { - // this is a valid, sequenced packet, so process it - if (cl.state != ClientStates.CS_ZOMBIE) { - cl.lastmessage = realtime // don't timeout - val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) - if (body == null) dropClient(cl) - else handleClientMessages(cl, body) - } - } - break + val routed = connections.route(networkPacket, clients) ?: return@poll + if (routed.portChanged) Com.Printf("SV_ReadPackets: fixing up a translated port\n") + val client = routed.client + if (client.state != ClientStates.CS_ZOMBIE) { + client.lastmessage = realtime + val body = networkPacket.parseBodyFromClient(client.netchan.incoming_sequence) + if (body == null) dropClient(client) + else handleClientMessages(client, body) } } } diff --git a/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt new file mode 100644 index 00000000..a02f4af7 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt @@ -0,0 +1,104 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ServerConnectionsTest { + private fun address(host: Int = 1, port: Int = 10000) = netadr_t().apply { + type = NetAddrType.NA_IP + ip = byteArrayOf(192.toByte(), 0, 2, host.toByte()) + this.port = port + } + + @Test + fun `bounded challenges own addresses and reuse without refreshing issuance order`() { + var token = 32767 + val connections = ServerConnections(2) { token++ } + val first = address() + assertEquals(32767, connections.issueChallenge(first)) + first.ip[3] = 99 + assertEquals(0, connections.issueChallenge(address(2))) + assertEquals(32767, connections.issueChallenge(address(port = 54321))) + assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(address(), 32767)) + assertEquals(ServerConnections.ChallengeValidation.BAD, connections.validateChallenge(address(), 123)) + assertEquals(1, connections.issueChallenge(address(3))) + assertEquals(ServerConnections.ChallengeValidation.MISSING, connections.validateChallenge(address(), 32767)) + assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(address(2), 0)) + connections.reset() + assertEquals(ServerConnections.ChallengeValidation.MISSING, connections.validateChallenge(address(2), 0)) + assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(netadr_t(), 123)) + assertEquals(ServerConnections.ChallengeValidation.MISSING, + connections.validateChallenge(address().apply { ip = byteArrayOf(127, 0, 0, 1); port = 0 }, 0)) + } + + @Test + fun `reconnect uses actual slot index after a free slot and respects Host time boundary`() { + NetworkTransport.standalone(LocalSide.SECOND).use { transport -> + fun client(state: ClientStates) = ClientNetworkInfo(netchan_t(transport) { 0L }).apply { + this.state = state + netchan.setup(Defines.NS_SERVER, address(), 7) + lastconnect = 100 + } + val free = client(ClientStates.CS_FREE) + val occupied = client(ClientStates.CS_CONNECTED) + val clients = listOf(free, occupied) + val connections = ServerConnections() + assertEquals(ServerConnections.Admission.TooSoon, + connections.selectSlot(clients, address(port = 20000), 7, 599, 500f)) + assertEquals(ServerConnections.Admission.Slot(1, true), + connections.selectSlot(clients, address(port = 20000), 7, 600, 500f)) + // A matching source port can identify an existing connection even if qport changed. + assertEquals(ServerConnections.Admission.Slot(1, true), + connections.selectSlot(clients, address(), 8, 600, 500f)) + assertEquals(ServerConnections.Admission.Slot(0, false), + connections.selectSlot(clients, address(2), 7, 600, 500f)) + assertEquals(ServerConnections.Admission.Full, + connections.selectSlot(listOf(occupied), address(2), 7, 600, 500f)) + occupied.netchan.setup(Defines.NS_SERVER, netadr_t(), 7) + assertEquals(ServerConnections.Admission.Slot(1, true), + connections.selectSlot(clients, netadr_t(), 7, 100, 500f)) + } + } + + private fun packet(sequence: Int, port: Int, qport: Int = 7, host: Int = 1): NetworkPacket { + val source = address(host, port) + val bytes = PacketCodec.encodeSequenced(sequence, 0, qport, PacketBytes.copyOf(byteArrayOf())) + val envelope = (PacketCodec.decode(bytes, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + return NetworkPacket.fromEnvelope(NetworkAddress.Udp(IpAddress.fromBytes(source.ip), port), envelope) + } + + @Test + fun `routing rejects wrong peers and stale packets before committing a translated port`() { + NetworkTransport.standalone(LocalSide.SECOND).use { transport -> + val client = ClientNetworkInfo(netchan_t(transport) { 0L }).apply { + state = ClientStates.CS_CONNECTED + netchan.setup(Defines.NS_SERVER, address(), 7) + } + val connections = ServerConnections() + val clients = listOf(client) + assertNull(connections.route(packet(1, 20000, qport = 8), clients)) + assertNull(connections.route(packet(1, 20000, host = 2), clients)) + assertEquals(10000, client.netchan.remote_address.port) + assertEquals(ServerConnections.Routed(client, true), connections.route(packet(2, 20000), clients)) + assertEquals(20000, client.netchan.remote_address.port) + assertNull(connections.route(packet(1, 30000), clients)) + assertNull(connections.route(packet(2, 30000), clients)) + assertEquals(20000, client.netchan.remote_address.port) + assertEquals(2, client.netchan.incoming_sequence) + assertEquals(ServerConnections.Routed(client, false), connections.route(packet(3, 20000), clients)) + client.state = ClientStates.CS_ZOMBIE + assertNotNull(connections.route(packet(4, 20000), clients)) + client.state = ClientStates.CS_FREE + assertNull(connections.route(packet(5, 40000), clients)) + assertEquals(20000, client.netchan.remote_address.port) + } + } +} From 85f072c0196f665512defea1fbb275d7094b322e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 13:17:41 +0100 Subject: [PATCH 31/57] Remove implicit global channel and address ownership --- .../java/jake2/server/GameImportsTest.java | 7 +-- .../jake2/server/CakeHostApplicationTest.kt | 16 ++++--- .../jake2/server/EmbeddedCakeHostTest.kt | 2 +- client/src/main/java/jake2/client/CL.java | 8 ++-- .../java/jake2/client/client_static_t.java | 2 +- .../main/java/jake2/qcommon/network/NET.java | 41 ++++++++++------- .../java/jake2/qcommon/network/Netchan.java | 21 +-------- .../qcommon/network/NetworkSettings.java | 22 +++++++++ .../java/jake2/qcommon/network/netadr_t.java | 46 +++++++++---------- .../java/jake2/qcommon/network/netchan_t.java | 31 +++---------- .../qcommon/network/runtime/PacketNetwork.kt | 4 +- .../qcommon/network/NetAddressBridgeTest.java | 41 +++++++++++++++++ .../qcommon/network/NetPacketReceiveTest.java | 4 +- .../network/NetSocketOwnershipTest.java | 6 +-- .../jake2/qcommon/network/NetchanTest.java | 30 +++++++----- .../kotlin/jake2/qcommon/OutputFailureTest.kt | 23 ++++++---- .../java/jake2/server/ClientNetworkInfo.java | 4 -- 17 files changed, 177 insertions(+), 131 deletions(-) create mode 100644 qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java create mode 100644 qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 65fb8a46..83179aca 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -31,6 +31,7 @@ public class GameImportsTest { @TempDir Path writeRoot; private Path previousWriteRoot; private ServerHost manager; + private final TransportServerNetwork channelNetwork = TransportServerNetwork.standalone(); private ServerStates previousServerState; @BeforeEach @@ -47,7 +48,7 @@ public void setup() { @AfterEach public void cleanup() { - if (manager != null) manager.close(); + try { if (manager != null) manager.close(); } finally { channelNetwork.close(); } Globals.server_state = previousServerState; EngineWriteRoot.setRoot(previousWriteRoot); Cvar.getInstance().clear(); @@ -221,7 +222,7 @@ private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { entity.setClient(new GamePlayerInfo(0)); entity.getClient().getPlayerState().pmove.origin[2] = 64 * 8; entity.s.modelindex = modelIndex; - ClientNetworkInfo client = new ClientNetworkInfo(); + ClientNetworkInfo client = new ClientNetworkInfo(channelNetwork.newChannel()); client.gameName = game.name; client.edict = entity; return client; @@ -272,7 +273,7 @@ public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { @Test public void clientPrintQueueRespectsItsMessageLevel() { - ClientNetworkInfo client = new ClientNetworkInfo(); + ClientNetworkInfo client = new ClientNetworkInfo(channelNetwork.newChannel()); client.messagelevel = Defines.PRINT_HIGH; client.queuePrint(Defines.PRINT_LOW, "hidden"); client.queuePrint(Defines.PRINT_HIGH, "visible"); diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index 937a1fe9..4cac7143 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -15,9 +15,8 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.network.Netchan -import jake2.qcommon.Defines.NS_SERVER -import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.transport.LoopbackLink +import jake2.qcommon.network.transport.PacketBytes import org.demoth.cake.Cake import org.demoth.cake.ui.menu.MenuBackend import org.demoth.cake.ui.menu.HostGameFormState @@ -44,7 +43,12 @@ class CakeHostApplicationTest { val previousHome = System.getProperty("user.home") System.setProperty("user.home", temp.toString()) var failure: Throwable? = null - val host = EmbeddedCakeHost(temp.resolve(".cake")) + val localLink = LoopbackLink() + val host = EmbeddedCakeHost(temp.resolve(".cake"), localLink) + fun staleLocalReply() { + localLink.second.send(localLink.first.localAddress, + PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "client_connect".toByteArray())) + } try { CakeStartupBootstrap.ensureInitialized() Cvar.getInstance().clear() @@ -118,7 +122,7 @@ class CakeHostApplicationTest { } 3 -> { // An old local handshake must not attach while targeting a UDP server. - Netchan.sendConnectionlessPacket(NS_SERVER, netadr_t(), ConnectionlessCommand.client_connect, "") + staleLocalReply() cake!!.render() assertEquals("CONNECTING", clientState(cake!!)) val current = temp.resolve(".cake/application-test/save/current/game.ssv.json") @@ -134,7 +138,7 @@ class CakeHostApplicationTest { } 4 -> { // Nor may it resurrect a disconnected client after rejected startup. - Netchan.sendConnectionlessPacket(NS_SERVER, netadr_t(), ConnectionlessCommand.client_connect, "") + staleLocalReply() cake!!.render() assertEquals("DISCONNECTED", clientState(cake!!)) Cbuf.AddText("set coop 0; set deathmatch 0; set maxclients 1; map testbox") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index f71c3f3e..bac01c58 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -156,7 +156,7 @@ class EmbeddedCakeHostTest { Files.createDirectories(partial.parent) Files.write(partial, byteArrayOf(1, 2, 3)) } - val client = ClientNetworkInfo() + val client = ClientNetworkInfo(host.clientNetwork.newChannel()) val downloads = CakeDownloadSession( sendReliable = { command -> client.gameName = "default" diff --git a/client/src/main/java/jake2/client/CL.java b/client/src/main/java/jake2/client/CL.java index 3275a37d..386d4bd7 100644 --- a/client/src/main/java/jake2/client/CL.java +++ b/client/src/main/java/jake2/client/CL.java @@ -371,7 +371,7 @@ public static class cheatvar_t { } to = netadr_t.fromString(ClientGlobals.rcon_address.string, Defines.PORT_SERVER); } - Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, to, ConnectionlessCommand.rcon, message.toString()); + NET.sendConnectionlessPacket(Defines.NS_CLIENT, to, ConnectionlessCommand.rcon, message.toString()); }; private static Command Disconnect_f = (List args) -> Com.Error(Defines.ERR_DROP, "Disconnected from server"); @@ -589,7 +589,7 @@ private static void SendConnectPacket() { int port = (int) Cvar.getInstance().VariableValue("qport"); Globals.userinfo_modified = false; - Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, adr, ConnectionlessCommand.connect, + NET.sendConnectionlessPacket(Defines.NS_CLIENT, adr, ConnectionlessCommand.connect, " " + Defines.PROTOCOL_VERSION + " " + port + " " + ClientGlobals.cls.challenge + " \"" + Cvar.getInstance().Userinfo() + "\"\n"); } @@ -630,7 +630,7 @@ private static void CheckForResend() { Com.Printf("Connecting to " + ClientGlobals.cls.servername + "...\n"); - Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, adr, ConnectionlessCommand.getchallenge, "\n"); + NET.sendConnectionlessPacket(Defines.NS_CLIENT, adr, ConnectionlessCommand.getchallenge, "\n"); } /** @@ -758,7 +758,7 @@ private static void CL_ConnectionlessPacket(NetworkPacket packet) { // ping from somewhere case ping: - Netchan.sendConnectionlessPacket(Defines.NS_CLIENT, packet.from, ConnectionlessCommand.ack, ""); + NET.sendConnectionlessPacket(Defines.NS_CLIENT, packet.from, ConnectionlessCommand.ack, ""); break; // challenge from the server we are connecting to diff --git a/client/src/main/java/jake2/client/client_static_t.java b/client/src/main/java/jake2/client/client_static_t.java index 9f59a226..b69bb40d 100644 --- a/client/src/main/java/jake2/client/client_static_t.java +++ b/client/src/main/java/jake2/client/client_static_t.java @@ -53,7 +53,7 @@ public class client_static_t { int quakePort; // a 16 bit value that allows quake servers // to work around address translating routers - public netchan_t netchan = new netchan_t(); + public netchan_t netchan = jake2.qcommon.network.NET.newChannel(jake2.qcommon.Defines.NS_CLIENT); public int serverProtocol; // in case we are doing some kind of version hack public int challenge; // from the server to use for connecting diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 3452c106..1ecf60bf 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -23,10 +23,12 @@ package jake2.qcommon.network; import jake2.qcommon.Com; +import jake2.qcommon.network.messages.ConnectionlessCommand; +import jake2.qcommon.sizebuf_t; +import jake2.qcommon.util.Lib; import jake2.qcommon.Defines; import jake2.qcommon.Globals; import jake2.qcommon.exec.Cvar; -import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.network.messages.NetworkPacket; import jake2.qcommon.network.runtime.NetworkTransport; import jake2.qcommon.network.protocol.PacketCodec; @@ -45,10 +47,7 @@ @Deprecated public final class NET { - /** Legacy address bridge; transport endpoints themselves use immutable addresses. */ - public static final netadr_t net_local_adr = new netadr_t(); - - // Compatibility composition root until application controllers receive their transports. + // Archived-client compatibility owner; active applications supply their own transports. private static NetworkTransport[] transports = newTransports(); private static NetworkTransport[] newTransports() { @@ -59,6 +58,11 @@ private static NetworkTransport[] newTransports() { }; } + /** Archived-client compatibility: explicitly borrow the facade's current role transport. */ + public static netchan_t newChannel(int role) { + return new netchan_t(transports[role], () -> System.nanoTime() / 1_000_000L); + } + public static UdpDatagramEndpoint udpEndpoint(int role) { return transports[role].getUdpEndpoint(); } @@ -127,17 +131,6 @@ public static SendResult sendPacket(int role, PacketBytes payload, netadr_t to) } } - /** - * Declare protected network settings after startup config has supplied early values. - */ - static void registerCvars() { - Cvar cvars = Cvar.getInstance(); - cvars.register("port", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_SERVER, Defines.CVAR_NOSET, "Server UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); - cvars.register("ip", CvarTypes.STRING, "localhost", Defines.CVAR_NOSET, "Local bind address; empty or localhost binds all interfaces"); - cvars.register("clientport", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_CLIENT, Defines.CVAR_NOSET, "Client UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); - cvars.register("thinclient", CvarTypes.BOOLEAN, false, 0, "Do not open a server UDP socket"); - } - /** Legacy combined socket policy. New session owners configure each role explicitly. */ public static void OpenIP() { Cvar cvars = Cvar.getInstance(); @@ -227,6 +220,22 @@ private static UdpDatagramEndpoint openEndpoint(String ip, int port) { } } + /** + * Out Of Band + */ + public static void sendConnectionlessPacket(int net_socket, netadr_t adr, ConnectionlessCommand cmd, String payload) { + String msg = cmd.name() + payload; + + sizebuf_t packet = new sizebuf_t(Defines.MAX_MSGLEN); + + // write the packet header + packet.writeInt(-1); // -1 sequence means connectionless (out of band) + packet.writeBytes(Lib.stringToBytes(msg), msg.length()); + + // send the datagram + NET.SendPacket(net_socket, packet.cursize, packet.data, adr); + } + /** * Shutdown - closes the sockets */ diff --git a/qcommon/src/main/java/jake2/qcommon/network/Netchan.java b/qcommon/src/main/java/jake2/qcommon/network/Netchan.java index da908f54..b4b5b86d 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/Netchan.java +++ b/qcommon/src/main/java/jake2/qcommon/network/Netchan.java @@ -27,10 +27,7 @@ of the License, or (at your option) any later version. import jake2.qcommon.exec.Cvar; import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.exec.cvar_t; -import jake2.qcommon.network.messages.ConnectionlessCommand; -import jake2.qcommon.sizebuf_t; import jake2.qcommon.sys.Timer; -import jake2.qcommon.util.Lib; /** * Netchan @@ -84,7 +81,7 @@ public final class Netchan { * * If the base part of the net address matches and the qport matches, then * the channel matches even if the IP port differs. The IP port should be - * updated to the new value before sending out any replies. + * updated after accepting the sequenced packet and before sending out any replies. * * * If there is no information that needs to be transfered on a given frame, @@ -108,7 +105,7 @@ public final class Netchan { public static void Netchan_Init() { Cvar cvars = Cvar.getInstance(); - NET.registerCvars(); + NetworkSettings.registerCvars(); showpackets = cvars.register("showpackets", CvarTypes.BOOLEAN, false, 0, "Log packet sizes, sequences and acknowledgements"); showdrop = cvars.register("showdrop", CvarTypes.BOOLEAN, false, 0, "Log dropped or out-of-order packets"); printNetwork = cvars.register("sv_print_network", CvarTypes.BOOLEAN, false, 0, "Log decoded incoming network messages"); @@ -119,21 +116,7 @@ public static void Netchan_Init() { "Client connection identifier for NAT port changes; not a UDP socket port"); } - /** - * Out Of Band - */ - public static void sendConnectionlessPacket(int net_socket, netadr_t adr, ConnectionlessCommand cmd, String payload) { - String msg = cmd.name() + payload; - - sizebuf_t packet = new sizebuf_t(Defines.MAX_MSGLEN); - // write the packet header - packet.writeInt(-1); // -1 sequence means connectionless (out of band) - packet.writeBytes(Lib.stringToBytes(msg), msg.length()); - - // send the datagram - NET.SendPacket(net_socket, packet.cursize, packet.data, adr); - } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java b/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java new file mode 100644 index 00000000..a07646d9 --- /dev/null +++ b/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java @@ -0,0 +1,22 @@ +package jake2.qcommon.network; + +import jake2.qcommon.Defines; +import jake2.qcommon.exec.Cvar; +import jake2.qcommon.exec.CvarTypes; + +/** Network configuration declarations; registering settings never creates transport resources. */ +public final class NetworkSettings { + private NetworkSettings() {} + + /** + * Declare protected network settings after startup config has supplied early values. + */ + public static void registerCvars() { + Cvar cvars = Cvar.getInstance(); + cvars.register("port", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_SERVER, Defines.CVAR_NOSET, "Server UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); + cvars.register("ip", CvarTypes.STRING, "localhost", Defines.CVAR_NOSET, "Local bind address; empty or localhost binds all interfaces"); + cvars.register("clientport", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_CLIENT, Defines.CVAR_NOSET, "Client UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); + cvars.register("thinclient", CvarTypes.BOOLEAN, false, 0, "Do not open a server UDP socket"); + } + +} diff --git a/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java b/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java index b933ce40..150678a1 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java @@ -28,8 +28,11 @@ import java.net.InetAddress; import java.net.UnknownHostException; -import static jake2.qcommon.network.NET.net_local_adr; +import java.util.Arrays; +import jake2.qcommon.network.transport.NetworkAddress; +import jake2.qcommon.network.transport.IpAddress; +/** Legacy address bridge; transport endpoints themselves use immutable addresses. */ public class netadr_t { public NetAddrType type; @@ -40,11 +43,11 @@ public class netadr_t { /** * @param s String address, like 10.132.12.54:4312 - * @return null if the string s is not a valid address + * @return a fresh address value, or null if the string s is not a valid address */ public static netadr_t fromString(String s, int defaultPort) { if (s.equalsIgnoreCase("localhost") || s.equalsIgnoreCase("loopback")) { - return net_local_adr; + return new netadr_t(); } else { try { netadr_t result = new netadr_t(); @@ -67,30 +70,23 @@ public static netadr_t fromString(String s, int defaultPort) { public netadr_t() { this.type = NetAddrType.NA_LOOPBACK; this.port = 0; // any - try { - // localhost / 127.0.0.1 - this.ip = InetAddress.getByName(null).getAddress(); - } catch (UnknownHostException e) { - } + // localhost / 127.0.0.1 + this.ip = new byte[] {127, 0, 0, 1}; } /** - * Seems to return true, if the address is is on 127.0.0.1. + * Returns true for in-process loopback addresses. A UDP address on 127.0.0.1 is still remote. */ public boolean IsLocalAddress() { - return this.compareIp(net_local_adr); + return type == NetAddrType.NA_LOOPBACK; } public boolean compareIp(netadr_t other) { - return (this.ip[0] == other.ip[0] - && this.ip[1] == other.ip[1] - && this.ip[2] == other.ip[2] - && this.ip[3] == other.ip[3] - && this.port == other.port); + return Arrays.equals(ip, other.ip) && port == other.port; } /** * Compares ip address without the port. - * Returns true for a LOOPBACK type - assuming that singleplayer client is the first one + * Returns true for matching LOOPBACK types; the owned transport enforces local-link identity. */ public boolean CompareBaseAdr(netadr_t other) { if (this.type != other.type) @@ -100,10 +96,7 @@ public boolean CompareBaseAdr(netadr_t other) { return true; if (this.type == NetAddrType.NA_IP) { - return this.ip[0] == other.ip[0] - && this.ip[1] == other.ip[1] - && this.ip[2] == other.ip[2] - && this.ip[3] == other.ip[3]; + return Arrays.equals(ip, other.ip); } return false; } @@ -123,13 +116,18 @@ public InetAddress getInetAddress() throws UnknownHostException { } } + /** Resolve this legacy destination using the owner's explicit local peer, never a global role. */ + public NetworkAddress toNetworkAddress(NetworkAddress.Local localPeer) throws UnknownHostException { + if (type == NetAddrType.NA_LOOPBACK) return localPeer; + if (type != NetAddrType.NA_IP && type != NetAddrType.NA_BROADCAST) + throw new IllegalArgumentException("Unsupported address type " + type); + return new NetworkAddress.Udp(IpAddress.fromInetAddress(getInetAddress()), port); + } + public void set(netadr_t from) { type = from.type; port = from.port; - ip[0] = from.ip[0]; - ip[1] = from.ip[1]; - ip[2] = from.ip[2]; - ip[3] = from.ip[3]; + ip = from.ip.clone(); } public String toString() { diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java index a8093238..580931ef 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java @@ -30,8 +30,6 @@ import jake2.qcommon.network.protocol.*; import jake2.qcommon.network.transport.PacketBytes; import jake2.qcommon.network.transport.SendResult; -import jake2.qcommon.network.transport.NetworkAddress; -import jake2.qcommon.network.transport.IpAddress; import jake2.qcommon.network.runtime.NetworkTransport; import jake2.qcommon.sizebuf_t; @@ -68,22 +66,13 @@ public class netchan_t { private final NetworkTransport transport; private ReliableChannel channel = new ReliableChannel(ChannelRole.CLIENT, 0); - /** @deprecated Inject NetworkTransport and a clock instead of using the global transport. */ - @Deprecated - public netchan_t() { - this(() -> System.nanoTime() / 1_000_000L); - } - - /** @deprecated Inject NetworkTransport alongside the clock. */ - @Deprecated - public netchan_t(LongSupplier clockMillis) { - this(null, clockMillis); - } - - /** The channel borrows its owner's transport; clearing a connection never closes it. */ + /** + * The channel borrows its owner's transport; clearing a connection never closes it. + * Supply both NetworkTransport and a clock explicitly instead of selecting a global transport. + */ public netchan_t(NetworkTransport transport, LongSupplier clockMillis) { - this.transport = transport; - this.clockMillis = clockMillis; + this.transport = java.util.Objects.requireNonNull(transport); + this.clockMillis = java.util.Objects.requireNonNull(clockMillis); } public void setup(int sock, netadr_t adr, int qport) { @@ -159,14 +148,8 @@ public boolean transmit(Collection unreliable) { } private SendResult send(PacketBytes bytes) { - if (transport == null) return NET.sendPacket(sock, bytes, remote_address); - if (remote_address.type == NetAddrType.NA_LOOPBACK) - return transport.send(transport.getLocalPeer(), bytes); - if (remote_address.type != NetAddrType.NA_IP && remote_address.type != NetAddrType.NA_BROADCAST) - throw new IllegalArgumentException("Unsupported address type " + remote_address.type); try { - return transport.send(new NetworkAddress.Udp( - IpAddress.fromInetAddress(remote_address.getInetAddress()), remote_address.port), bytes); + return transport.send(remote_address.toNetworkAddress(transport.getLocalPeer()), bytes); } catch (java.io.IOException error) { return new SendResult.Failed(error); } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index 2e5c767e..7506e0e5 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -3,7 +3,6 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Com import jake2.qcommon.Defines import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.Netchan import jake2.qcommon.network.netadr_t import jake2.qcommon.network.netchan_t @@ -100,8 +99,7 @@ class PacketNetwork( val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) require(text.size <= Defines.MAX_MSGLEN - 4) val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() - val destination = if (to.type == NetAddrType.NA_LOOPBACK) transport.localPeer else - NetworkAddress.Udp(IpAddress.fromInetAddress(to.inetAddress), to.port) + val destination = to.toNetworkAddress(transport.localPeer) val result = transport.send(destination, PacketBytes.copyOf(bytes)) if (result is SendResult.Failed) Com.Println("$label send error: ${result.cause}") } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java new file mode 100644 index 00000000..7139f5b8 --- /dev/null +++ b/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java @@ -0,0 +1,41 @@ +package jake2.qcommon.network; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class NetAddressBridgeTest { + @Test + void localParsingReturnsIndependentValuesAndUdpLoopbackRemainsRemote() { + var first = netadr_t.fromString("localhost", 27910); + var second = netadr_t.fromString("loopback", 27910); + assertNotSame(first, second); + assertNotSame(first.ip, second.ip); + first.ip[0] = 99; + first.port = 1234; + first.type = NetAddrType.NA_IP; + var third = netadr_t.fromString("LOCALHOST", 27910); + assertArrayEquals(new byte[]{127, 0, 0, 1}, third.ip); + assertEquals(0, third.port); + assertTrue(third.IsLocalAddress()); + assertFalse(netadr_t.fromString("127.0.0.1:27910", 27910).IsLocalAddress()); + } + + @Test + void copyingOwnsAllAddressBytesAndComparisonsUseTheFullAddress() { + var source = new netadr_t(); + source.type = NetAddrType.NA_IP; + source.ip = new byte[]{32, 1, 13, (byte)184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + source.port = 27910; + var copy = new netadr_t(); + copy.set(source); + assertNotSame(source.ip, copy.ip); + assertArrayEquals(source.ip, copy.ip); + assertTrue(source.compareIp(copy)); + copy.port++; + assertFalse(source.compareIp(copy)); + assertTrue(source.CompareBaseAdr(copy)); + copy.ip[15] = 2; + assertFalse(source.CompareBaseAdr(copy)); + assertEquals(1, source.ip[15]); + } +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index d54d1aa1..6771072e 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -89,7 +89,7 @@ void connectionlessStringEndsAtDatagramBoundaryWithoutTerminator() throws Except @Test void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { NET.SendPacket(NS_CLIENT, 8, sequencedPacket(8, false), new netadr_t()); - Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); assertNull(NET.receiveLoopbackPacket(NS_SERVER)); NetworkPacket next = NET.receiveLoopbackPacket(NS_SERVER); @@ -99,7 +99,7 @@ void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { @Test void serverPrintReplySeparatesCommandFromMultilineParameters() { - Netchan.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.print, + NET.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.print, "\nFirst line\nSecond line\n"); NetworkPacket packet = NET.receiveLoopbackPacket(NS_CLIENT); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index 95c4a065..3e464ade 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -91,10 +91,10 @@ void occupiedClientPortFallsBackOnExplicitBindAddress() throws Exception { @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { - Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); assertNotNull(NET.receiveLoopbackPacket(NS_SERVER)); - Netchan.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.client_connect, ""); - Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + NET.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.client_connect, ""); + NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); NET.clearLoopbacks(); assertNull(NET.receiveLoopbackPacket(NS_CLIENT)); assertNull(NET.receiveLoopbackPacket(NS_SERVER)); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index 2b68794d..3ceb14ba 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -8,6 +8,10 @@ import jake2.qcommon.network.protocol.PacketDecodeResult; import jake2.qcommon.network.protocol.PacketDirection; import jake2.qcommon.network.transport.IpAddress; +import jake2.qcommon.network.transport.LoopbackLink; +import jake2.qcommon.network.runtime.NetworkTransport; +import jake2.qcommon.network.runtime.PacketNetwork; +import jake2.qcommon.network.runtime.NetworkRole; import jake2.qcommon.network.transport.NetworkAddress; import jake2.qcommon.network.transport.PacketBytes; import jake2.qcommon.sizebuf_t; @@ -19,35 +23,39 @@ import static org.junit.jupiter.api.Assertions.*; class NetchanTest { + private NetworkTransport client; + private NetworkTransport server; + private PacketNetwork serverPackets; @BeforeEach void setUp() { - NET.Config(false); - NET.clearLoopbacks(); + var link = new LoopbackLink(); + client = new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()); + server = new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()); + serverPackets = new PacketNetwork(server, NetworkRole.SERVER); Cvar.getInstance().clear(); Globals.curtime = 0; Netchan.Netchan_Init(); } @AfterEach - void cleanup() { - NET.Config(false); - NET.clearLoopbacks(); + void cleanup() throws Exception { + try { client.close(); } finally { server.close(); } Cvar.getInstance().clear(); } @Test void outgoingClientPacketUsesItsSetupQport() { Netchan.qport.forceSet(77); - var channel = new netchan_t(); + var channel = new netchan_t(client, () -> Globals.curtime); channel.setup(NS_CLIENT, new netadr_t(), 1234); assertTrue(channel.transmit(null)); - assertEquals(1234, NET.receiveLoopbackPacket(NS_SERVER).qport); + assertEquals(1234, serverPackets.receive().qport); } @Test void unavailableTransportDoesNotAdvanceOrLoseStagedMessages() { - var channel = new netchan_t(() -> Globals.curtime); + var channel = new netchan_t(client, () -> Globals.curtime); var remote = new netadr_t(); remote.type = NetAddrType.NA_IP; remote.port = 27910; @@ -70,7 +78,7 @@ void unavailableTransportDoesNotAdvanceOrLoseStagedMessages() { @Test void unexpectedSendFailureReleasesPreparedTransaction() { - var channel = new netchan_t(); + var channel = new netchan_t(client, () -> Globals.curtime); channel.setup(NS_CLIENT, new netadr_t(), 7); channel.reliablePending.add(new StringCmdMessage("new")); channel.remote_address = null; @@ -83,7 +91,7 @@ void unexpectedSendFailureReleasesPreparedTransaction() { @Test void recordsSentPacketSizeAfterPacketIsBuilt() { - netchan_t netchan = new netchan_t(() -> Globals.curtime); + netchan_t netchan = new netchan_t(server, () -> Globals.curtime); netchan.setup(NS_SERVER, new netadr_t(), 0); Globals.curtime = 100; @@ -94,7 +102,7 @@ void recordsSentPacketSizeAfterPacketIsBuilt() { @Test void smoothsPingFromAcknowledgedPacketSequence() { - netchan_t netchan = new netchan_t(() -> Globals.curtime); + netchan_t netchan = new netchan_t(server, () -> Globals.curtime); netchan.setup(NS_SERVER, new netadr_t(), 0); Globals.curtime = 100; diff --git a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt index 10ec77c9..c24f67f6 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -1,6 +1,8 @@ package jake2.qcommon -import jake2.qcommon.network.NET +import jake2.qcommon.network.runtime.* +import jake2.qcommon.network.transport.LocalSide +import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.netadr_t import org.junit.jupiter.api.Assertions.* @@ -45,22 +47,23 @@ class OutputFailureTest { @ParameterizedTest @EnumSource(NetAddrType::class, names = ["NA_IPX", "NA_BROADCAST_IPX"]) fun `unsupported destination propagates without closing the socket`(type: NetAddrType) { - NET.Config(false) val cvars = Cvar.getInstance() cvars.clear() Netchan.Netchan_Init() cvars.find("port", Int::class.javaObjectType).forceSet(0) + val transport = NetworkTransport.standalone(LocalSide.SECOND) try { - assertTrue(NET.configureServer(true)) - val endpoint = NET.udpEndpoint(Defines.NS_SERVER) - val failure = assertThrows(IllegalArgumentException::class.java) { - NET.SendPacket(Defines.NS_SERVER, 1, byteArrayOf(1), netadr_t().apply { this.type = type }) + PacketNetwork(transport, NetworkRole.SERVER).use { packets -> + assertTrue(packets.configure(true)) + val endpoint = transport.udpEndpoint!! + val failure = assertThrows(IllegalArgumentException::class.java) { + packets.sendConnectionless(netadr_t().apply { this.type = type }, ConnectionlessCommand.print, "\nerror") + } + assertTrue(failure.message!!.contains(type.name)) + assertSame(endpoint, transport.udpEndpoint) + assertTrue(endpoint.isOpen) } - assertTrue(failure.message!!.contains(type.name)) - assertSame(endpoint, NET.udpEndpoint(Defines.NS_SERVER)) - assertTrue(endpoint.isOpen) } finally { - NET.configureServer(false) cvars.clear() } } diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index c8567ea8..75f1579e 100644 --- a/server/src/main/java/jake2/server/ClientNetworkInfo.java +++ b/server/src/main/java/jake2/server/ClientNetworkInfo.java @@ -38,10 +38,6 @@ of the License, or (at your option) any later version. */ public class ClientNetworkInfo { - public ClientNetworkInfo() { - this(new netchan_t()); - } - public ClientNetworkInfo(netchan_t channel) { this.netchan = java.util.Objects.requireNonNull(channel); for (int n = 0; n < Defines.UPDATE_BACKUP; n++) { From f785ed98700e9f89a0c732ac00bea4b4e81d819b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 15:19:10 +0100 Subject: [PATCH 32/57] Track opt-in graphics and game test environment inputs --- cake/cake-fullgame/build.gradle.kts | 5 +++++ cake/core/build.gradle | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index 8d6fc600..e4abf52f 100644 --- a/cake/cake-fullgame/build.gradle.kts +++ b/cake/cake-fullgame/build.gradle.kts @@ -21,6 +21,11 @@ dependencies { // Real-application tests bootstrap process-wide engine state and replace user.home. // Keep each opt-in test class in its own application process. 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 -> + inputs.property(name, providers.environmentVariable(name).orElse("")) + } if (providers.environmentVariable("CAKE_HOST_GAME_TESTS").orNull == "1") { forkEvery = 1 } diff --git a/cake/core/build.gradle b/cake/core/build.gradle index 54ac5bc4..d449cca9 100644 --- a/cake/core/build.gradle +++ b/cake/core/build.gradle @@ -37,3 +37,8 @@ gitProperties { keys = ['git.build.version', 'git.commit.id.abbrev', 'git.dirty'] failOnNoGitDirectory = false } + +tasks.withType(Test).configureEach { + // Enabling graphics tests must invalidate an earlier run that skipped them. + inputs.property('CAKE_VIDEO_GL_TESTS', providers.environmentVariable('CAKE_VIDEO_GL_TESTS').orElse('')) +} From f243f2b6e9ba5805a06a6ce47e29985c8277d2ea Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 16:00:59 +0100 Subject: [PATCH 33/57] Remove NET facade and migrate its regression fixtures --- README.md | 2 + .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 1 + .../jake2/server/CakeLocalSessionGlTest.kt | 1 - info/Networking.md | 21 +- .../main/java/jake2/qcommon/network/NET.java | 259 ------------------ .../qcommon/network/NetworkSettings.java | 1 - .../network/runtime/NetworkTransport.kt | 1 + .../qcommon/network/runtime/PacketNetwork.kt | 7 +- .../qcommon/network/NetPacketReceiveTest.java | 37 +-- .../network/NetSocketOwnershipTest.java | 63 +++-- .../qcommon/network/OwnedNetworkPair.java | 25 ++ .../main/kotlin/jake2/server/ServerNetwork.kt | 1 + 12 files changed, 107 insertions(+), 312 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/NET.java create mode 100644 qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java diff --git a/README.md b/README.md index 37f60deb..a7b15197 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ What is Cake? Important note about client side code --------------------------------------------------- Jake2 client in its vanilla form is archived in `jake2-legacy` branch and will not be maintained. +The historical `client/` sources are excluded from the current Gradle build and may reference retired engine APIs. +Use the `jake2-legacy` branch to build that client; network wire compatibility remains required for the current applications. Even though one of the core ideas of this project was to reuse and improve (rather than reimplement from scratch) - the client side code was particularly hard to maintain and develop. Firstly, because it is written on top of the unmaintained and obsolete second LWJGL version, diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 8612d09d..a275e0fe 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -40,6 +40,7 @@ class EmbeddedCakeHost( internal fun udpEndpoint(role: Int) = if (role == Defines.NS_CLIENT) clientTransport.udpEndpoint else serverTransport.udpEndpoint + /** Whole-session boundary only: discard both local queues while preserving UDP bindings. */ private fun clearLocalTraffic() { localLink.first.clearIncoming() localLink.second.clearIncoming() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index 0e6b48d7..87fa089a 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -10,7 +10,6 @@ import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.NET import org.demoth.cake.Cake import org.demoth.cake.CakeStartupBootstrap import org.demoth.cake.fullgame.EmbeddedCakeHost diff --git a/info/Networking.md b/info/Networking.md index d909ae7e..2ad9f69a 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -1,5 +1,23 @@ # Quake 2 Networking Communication Protocol +## Transport ownership + +Client and server controllers own separate `NetworkTransport` instances. Each role +configures only its own UDP endpoint; loopback communication needs no socket. A +client may fall back to an ephemeral UDP port when its configured port is occupied. +Applications compose these owners explicitly, replacing the former combined global +socket policy and `NET` facade. The archived client's API/ABI is not maintained; +the Quake II network wire protocol remains compatible. + +Local traffic uses a private `LoopbackLink`. Session stop discards incoming local +traffic while preserving final messages already queued for the peer. Map changes +retain reconnect traffic; clearing both local queues belongs at a whole-session +boundary. Closing an owner closes its resources independently of the peer's. + +Packet decoding has explicit, side-effect-free outcomes below the engine adapter. +Dedicated-server tick waiting currently uses a timed sleep rather than socket +readiness. Network ownership does not impose a global client/server sleep policy. + ## NetworkPacket Every frame both client and servers are sending a jake2.qcommon.network.messages.NetworkPacket to each other. @@ -7,7 +25,8 @@ Each such packet contains a header and several (Client|Server)Messages ## "Connectionless" (Out of band) packet Such packets/messages are sent ad-hoc and don't require a running map instance, therefore could be sent by not connected clients. -They are sent separately, not as a part of jake2.qcommon.network.messages.NetworkPacket. +They are separate datagrams rather than messages inside a sequenced game-channel packet. +`PacketCodec` identifies them by their leading -1 sequence marker; the engine adapter can expose both kinds as `NetworkPacket` values. Mostly such packets contain a single string (though there are some cases when 2 strings are sent - `info` & `print`) see `jake2.qcommon.network.messages.ConnectionlessCommand` diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java deleted file mode 100644 index 1ecf60bf..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ /dev/null @@ -1,259 +0,0 @@ -/* - * NET.java Copyright (C) 2003 - */ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ -package jake2.qcommon.network; - -import jake2.qcommon.Com; -import jake2.qcommon.network.messages.ConnectionlessCommand; -import jake2.qcommon.sizebuf_t; -import jake2.qcommon.util.Lib; -import jake2.qcommon.Defines; -import jake2.qcommon.Globals; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.network.messages.NetworkPacket; -import jake2.qcommon.network.runtime.NetworkTransport; -import jake2.qcommon.network.protocol.PacketCodec; -import jake2.qcommon.network.protocol.PacketDecodeResult; -import jake2.qcommon.network.protocol.PacketDirection; -import jake2.qcommon.network.transport.*; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.nio.channels.ClosedChannelException; - -import static jake2.qcommon.Defines.NS_SERVER; - -/** @deprecated Compatibility facade; inject an owned NetworkTransport through the client/server network boundary. */ -@Deprecated -public final class NET { - - // Archived-client compatibility owner; active applications supply their own transports. - private static NetworkTransport[] transports = newTransports(); - - private static NetworkTransport[] newTransports() { - LoopbackLink link = new LoopbackLink(); - return new NetworkTransport[] { - new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()), - new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()) - }; - } - - /** Archived-client compatibility: explicitly borrow the facade's current role transport. */ - public static netchan_t newChannel(int role) { - return new netchan_t(transports[role], () -> System.nanoTime() / 1_000_000L); - } - - public static UdpDatagramEndpoint udpEndpoint(int role) { - return transports[role].getUdpEndpoint(); - } - - public static void SendLoopPacket(int role, int length, byte[] data, netadr_t to) { - transports[role].send(transports[role].getLocalPeer(), PacketBytes.copyOf(data, length)); - } - - /** Receive only local traffic, including final messages after the UDP role has stopped. */ - public static NetworkPacket receiveLoopbackPacket(int role) { - return adaptReceived(transports[role].receiveLocal(), role == NS_SERVER); - } - - public static NetworkPacket receiveNetworkPacket(int role) { - return adaptReceived(transports[role].receive(), role == NS_SERVER); - } - - // Legacy nullable API and trace output; decoding itself has explicit, side-effect-free outcomes. - private static NetworkPacket adaptReceived(ReceiveResult result, boolean fromClient) { - if (result instanceof ReceiveResult.Failed failure) { - Com.Println("NET receive ERROR: " + failure.getCause()); - } - if (!(result instanceof ReceiveResult.Received received)) return null; - Datagram datagram = received.getDatagram(); - PacketDecodeResult decoded = PacketCodec.decode(datagram.getPayload(), - fromClient ? PacketDirection.FROM_CLIENT : PacketDirection.FROM_SERVER); - if (decoded instanceof PacketDecodeResult.Rejected rejected) { - if (Netchan.showdrop != null && Netchan.showdrop.getTypedValue()) { - Com.Println("NET dropped packet from " + datagram.getSource() + ": " + rejected.getReason()); - } - return null; - } - NetworkPacket packet = NetworkPacket.fromEnvelope(datagram.getSource(), - ((PacketDecodeResult.Decoded) decoded).getEnvelope()); - if (packet.isConnectionless() && Netchan.printNetwork != null && Netchan.printNetwork.getTypedValue()) { - Com.Printf("Network: Received from " + (fromClient ? "client" : "server") - + " connectionless: " + packet.connectionlessMessage + "\n"); - } - return packet; - } - - public static SendResult SendPacket(int role, int length, byte[] data, netadr_t to) { - return sendPacket(role, PacketBytes.copyOf(data, length), to); - } - - public static SendResult sendPacket(int role, PacketBytes payload, netadr_t to) { - if (to.type == NetAddrType.NA_LOOPBACK) { - return transports[role].send(transports[role].getLocalPeer(), payload); - } - UdpDatagramEndpoint endpoint = udpEndpoint(role); - if (endpoint == null) return new SendResult.Failed(new ClosedChannelException()); - if (to.type != NetAddrType.NA_BROADCAST && to.type != NetAddrType.NA_IP) { - throw new IllegalArgumentException("NET_SendPacket: unsupported address type " + to.type); - } - try { - NetworkAddress.Udp destination = new NetworkAddress.Udp( - IpAddress.fromInetAddress(to.getInetAddress()), to.port); - SendResult result = transports[role].send(destination, payload); - if (result instanceof SendResult.Failed failure) { - Com.Println("NET_SendPacket ERROR: " + failure.getCause() + " to " + to); - } - return result; - } catch (IOException | IllegalArgumentException error) { - Com.Println("NET_SendPacket ERROR: " + error + " to " + to); - return new SendResult.Failed(error instanceof IOException io ? io : new IOException(error)); - } - } - - /** Legacy combined socket policy. New session owners configure each role explicitly. */ - public static void OpenIP() { - Cvar cvars = Cvar.getInstance(); - if (!cvars.find("thinclient", Boolean.class).getTypedValue()) configureServer(true); - if (!cvars.find("dedicated", Boolean.class).getTypedValue()) configureClient(true); - } - - /** Configure only the server UDP endpoint; loopback never needs a socket. */ - public static boolean configureServer(boolean enabled) { - return configureRole(NS_SERVER, enabled, "port", false); - } - - /** Configure only the client UDP endpoint, retaining the legacy ephemeral-port fallback. */ - public static boolean configureClient(boolean enabled) { - return configureRole(Defines.NS_CLIENT, enabled, "clientport", true); - } - - private static boolean configureRole(int role, boolean enabled, String portName, boolean fallback) { - if (!enabled) { - closeRole(role); - return true; - } - if (udpEndpoint(role) != null && udpEndpoint(role).isOpen()) return true; - closeRole(role); - Cvar cvars = Cvar.getInstance(); - String ip = cvars.find("ip", String.class).getTypedValue(); - int port = cvars.find(portName, Integer.class).getTypedValue(); - UdpDatagramEndpoint endpoint = openEndpoint(ip, port); - if (endpoint == null && fallback) endpoint = openEndpoint(ip, Defines.PORT_ANY); - try { - transports[role].replaceUdp(endpoint); - } catch (IOException error) { - Com.Println("Closing previous UDP endpoint: " + error.getMessage()); - } - return endpoint != null; - } - - private static void closeRole(int role) { - try { - transports[role].replaceUdp(null); - } catch (IOException error) { - Com.Println("Closing UDP endpoint: " + error.getMessage()); - } - } - - /** Whole-session boundary only: preserve UDP bindings while retiring the local pair. */ - public static void clearLoopbacks() { - NetworkTransport[] previous = transports; - transports = newTransports(); - for (int role = 0; role < previous.length; role++) { - // UDP ownership remains with the role across a local-session reset. - try { - transports[role].replaceUdp(previous[role].detachUdp()); - previous[role].close(); - } catch (IOException error) { - Com.Println("Closing network transport: " + error.getMessage()); - } - } - } - - /** Discard incoming packets for one role without dropping the other role's final messages. */ - public static void clearLoopback(int role) { - transports[role].clearLocalIncoming(); - } - - /** Legacy combined policy for callers that own both socket roles. */ - public static void Config(boolean multiplayer) { - if (multiplayer) OpenIP(); - else { - configureClient(false); - configureServer(false); - } - } - - private static UdpDatagramEndpoint openEndpoint(String ip, int port) { - try { - int bindPort = port == Defines.PORT_ANY ? 0 : port; - InetSocketAddress bindAddress = ip == null || ip.isEmpty() || ip.equals("localhost") - ? new InetSocketAddress(bindPort) - : new InetSocketAddress(InetAddress.getByName(ip), bindPort); - UdpDatagramEndpoint endpoint = UdpDatagramEndpoint.bind(bindAddress); - Com.Println("Opened port: " + endpoint.getLocalAddress().getPort()); - return endpoint; - } catch (IOException error) { - Com.Println("NET bind ERROR: " + error); - return null; - } - } - - /** - * Out Of Band - */ - public static void sendConnectionlessPacket(int net_socket, netadr_t adr, ConnectionlessCommand cmd, String payload) { - String msg = cmd.name() + payload; - - sizebuf_t packet = new sizebuf_t(Defines.MAX_MSGLEN); - - // write the packet header - packet.writeInt(-1); // -1 sequence means connectionless (out of band) - packet.writeBytes(Lib.stringToBytes(msg), msg.length()); - - // send the datagram - NET.SendPacket(net_socket, packet.cursize, packet.data, adr); - } - - /** - * Shutdown - closes the sockets - */ - public static void Shutdown() { - // close sockets - Config(false); - } - - /** Sleeps msec or until net socket is ready. */ - public static void Sleep(int msec) { - if (udpEndpoint(NS_SERVER) == null - || (Globals.dedicated != null && !Globals.dedicated.getTypedValue())) - return; // we're not a server, just run full speed - - try { - //TODO: check for timeout - Thread.sleep(msec); - } catch (InterruptedException e) { - } - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java b/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java index a07646d9..41f773a3 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java @@ -16,7 +16,6 @@ public static void registerCvars() { cvars.register("port", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_SERVER, Defines.CVAR_NOSET, "Server UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); cvars.register("ip", CvarTypes.STRING, "localhost", Defines.CVAR_NOSET, "Local bind address; empty or localhost binds all interfaces"); cvars.register("clientport", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_CLIENT, Defines.CVAR_NOSET, "Client UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); - cvars.register("thinclient", CvarTypes.BOOLEAN, false, 0, "Do not open a server UDP socket"); } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt index cce44755..1cf1c265 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt @@ -80,6 +80,7 @@ class NetworkTransport( return count } + /** Discard this owner's incoming local packets without dropping the peer's final messages. */ fun clearLocalIncoming() = local.clearIncoming() @Throws(IOException::class) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index 7506e0e5..dc6a5861 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -37,7 +37,10 @@ class PacketNetwork( return netchan_t(transport) { System.nanoTime() / 1_000_000L } } - /** Reuse the binding until explicitly disabled. Only clients fall back to an ephemeral port. */ + /** + * Configure only this owner's UDP endpoint; loopback never needs a socket. + * Reuse the binding until explicitly disabled. Only clients fall back to an ephemeral port. + */ fun configure(enabled: Boolean): Boolean { check(!closed) { "$label network is closed" } if (!enabled) { @@ -95,6 +98,7 @@ class PacketNetwork( return packet } + /** Out-of-band datagram: a -1 sequence marker followed by Latin-1 command/payload bytes. */ fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) require(text.size <= Defines.MAX_MSGLEN - 4) @@ -114,6 +118,7 @@ class PacketNetwork( } } + /** Shutdown closes this owner's UDP and local endpoints, independently of the peer's resources. */ override fun close() { if (closed) return closed = true diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index 6771072e..a8222114 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -9,6 +9,7 @@ import jake2.qcommon.network.transport.IpAddress; import jake2.qcommon.network.transport.NetworkAddress; import jake2.qcommon.network.transport.PacketBytes; +import jake2.qcommon.network.transport.SendResult; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,18 +27,17 @@ import static org.junit.jupiter.api.Assertions.*; class NetPacketReceiveTest { + private OwnedNetworkPair pair; @BeforeEach void setup() { - NET.Config(false); - NET.clearLoopbacks(); Cvar.getInstance().clear(); Netchan.Netchan_Init(); + pair = new OwnedNetworkPair(); } @AfterEach void cleanup() { - NET.Config(false); - NET.clearLoopbacks(); + pair.close(); Cvar.getInstance().clear(); } @@ -80,29 +80,31 @@ void connectionlessStringEndsAtDatagramBoundaryWithoutTerminator() throws Except assertTrue(packet.isConnectionless()); assertEquals("x".repeat(MAX_MSGLEN - 4), packet.connectionlessMessage); - NET.SendPacket(NS_CLIENT, bytes.length, bytes, new netadr_t()); - NetworkPacket loopback = NET.receiveLoopbackPacket(NS_SERVER); + assertSame(SendResult.Sent.INSTANCE, pair.clientTransport.send( + pair.clientTransport.getLocalPeer(), PacketBytes.copyOf(bytes))); + NetworkPacket loopback = pair.server.receive(); assertNotNull(loopback); assertEquals(loopback.connectionlessMessage, packet.connectionlessMessage); } @Test void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { - NET.SendPacket(NS_CLIENT, 8, sequencedPacket(8, false), new netadr_t()); - NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + assertSame(SendResult.Sent.INSTANCE, pair.clientTransport.send( + pair.clientTransport.getLocalPeer(), PacketBytes.copyOf(sequencedPacket(8, false)))); + pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); - assertNull(NET.receiveLoopbackPacket(NS_SERVER)); - NetworkPacket next = NET.receiveLoopbackPacket(NS_SERVER); + assertNull(pair.server.receive()); + NetworkPacket next = pair.server.receive(); assertNotNull(next); assertEquals("getchallenge", next.connectionlessMessage); } @Test void serverPrintReplySeparatesCommandFromMultilineParameters() { - NET.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.print, + pair.server.sendConnectionless(new netadr_t(), ConnectionlessCommand.print, "\nFirst line\nSecond line\n"); - NetworkPacket packet = NET.receiveLoopbackPacket(NS_CLIENT); + NetworkPacket packet = pair.client.receive(); assertNotNull(packet); assertEquals("print", packet.connectionlessMessage); assertEquals("First line\nSecond line\n", packet.connectionlessParameters); @@ -117,16 +119,17 @@ private static byte[] sequencedPacket(int length, boolean fromClient) { return bytes; } - private static NetworkPacket receive(byte[] bytes, boolean fromClient) throws Exception { - int role = fromClient ? NS_SERVER : NS_CLIENT; + private NetworkPacket receive(byte[] bytes, boolean fromClient) throws Exception { + var network = fromClient ? pair.server : pair.client; + var transport = fromClient ? pair.serverTransport : pair.clientTransport; Cvar.getInstance().find(fromClient ? "port" : "clientport", Integer.class).forceSet(0); - assertTrue(fromClient ? NET.configureServer(true) : NET.configureClient(true)); + assertTrue(network.configure(true)); try (var sender = DatagramChannel.open()) { - var destination = new InetSocketAddress("127.0.0.1", NET.udpEndpoint(role).getLocalAddress().getPort()); + var destination = new InetSocketAddress("127.0.0.1", transport.getUdpEndpoint().getLocalAddress().getPort()); assertEquals(bytes.length, sender.send(ByteBuffer.wrap(bytes), destination)); long deadline = System.nanoTime() + 2_000_000_000L; while (System.nanoTime() < deadline) { - NetworkPacket packet = NET.receiveNetworkPacket(role); + NetworkPacket packet = network.receive(); if (packet != null) return packet; LockSupport.parkNanos(1_000_000); } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index 3e464ade..2ce908e3 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -17,50 +17,49 @@ import static org.junit.jupiter.api.Assertions.*; class NetSocketOwnershipTest { + private OwnedNetworkPair pair; @BeforeEach void setup() { - NET.Config(false); - NET.clearLoopbacks(); Cvar.getInstance().clear(); MainCommon.registerCvars(false); Netchan.Netchan_Init(); + pair = new OwnedNetworkPair(); Cvar.getInstance().find("port", Integer.class).forceSet(0); Cvar.getInstance().find("clientport", Integer.class).forceSet(0); } @AfterEach void cleanup() { - NET.Config(false); - NET.clearLoopbacks(); + pair.close(); Cvar.getInstance().clear(); } @Test void clientTeardownPreservesServerAndServerTeardownPreservesClient() { - assertTrue(NET.configureServer(true)); - var server = NET.udpEndpoint(NS_SERVER); - assertNull(NET.udpEndpoint(NS_CLIENT)); - assertTrue(NET.configureClient(true)); - NET.configureClient(false); - assertSame(server, NET.udpEndpoint(NS_SERVER)); + assertTrue(pair.server.configure(true)); + var server = pair.serverTransport.getUdpEndpoint(); + assertNull(pair.clientTransport.getUdpEndpoint()); + assertTrue(pair.client.configure(true)); + pair.client.configure(false); + assertSame(server, pair.serverTransport.getUdpEndpoint()); assertTrue(server.isOpen()); - assertNull(NET.udpEndpoint(NS_CLIENT)); - assertTrue(NET.configureClient(true)); - var client = NET.udpEndpoint(NS_CLIENT); - NET.configureServer(false); - assertSame(client, NET.udpEndpoint(NS_CLIENT)); + assertNull(pair.clientTransport.getUdpEndpoint()); + assertTrue(pair.client.configure(true)); + var client = pair.clientTransport.getUdpEndpoint(); + pair.server.configure(false); + assertSame(client, pair.clientTransport.getUdpEndpoint()); assertTrue(client.isOpen()); - assertNull(NET.udpEndpoint(NS_SERVER)); + assertNull(pair.serverTransport.getUdpEndpoint()); } @Test void failedServerBindReleasesChannelAndAllowsRetry() throws Exception { try (var occupied = new DatagramSocket(0)) { Cvar.getInstance().find("port", Integer.class).forceSet(occupied.getLocalPort()); - assertFalse(NET.configureServer(true)); - assertNull(NET.udpEndpoint(NS_SERVER)); + assertFalse(pair.server.configure(true)); + assertNull(pair.serverTransport.getUdpEndpoint()); } - assertTrue(NET.configureServer(true)); + assertTrue(pair.server.configure(true)); } @ParameterizedTest @@ -69,9 +68,9 @@ void explicitBindAddressSupportsEphemeralPorts(int port) throws Exception { Cvar.getInstance().find("ip", String.class).forceSet("127.0.0.1"); Cvar.getInstance().find("port", Integer.class).forceSet(port); - assertTrue(NET.configureServer(true)); - assertEquals(InetAddress.getByName("127.0.0.1"), NET.udpEndpoint(NS_SERVER).getLocalAddress().getIp().toInetAddress()); - assertTrue(NET.udpEndpoint(NS_SERVER).getLocalAddress().getPort() > 0); + assertTrue(pair.server.configure(true)); + assertEquals(InetAddress.getByName("127.0.0.1"), pair.serverTransport.getUdpEndpoint().getLocalAddress().getIp().toInetAddress()); + assertTrue(pair.serverTransport.getUdpEndpoint().getLocalAddress().getPort() > 0); } @Test @@ -80,23 +79,23 @@ void occupiedClientPortFallsBackOnExplicitBindAddress() throws Exception { try (var occupied = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0))) { Cvar.getInstance().find("clientport", Integer.class).forceSet(occupied.getLocalPort()); - assertTrue(NET.configureClient(true)); - var client = NET.udpEndpoint(NS_CLIENT); + assertTrue(pair.client.configure(true)); + var client = pair.clientTransport.getUdpEndpoint(); assertEquals(occupied.getLocalAddress(), client.getLocalAddress().getIp().toInetAddress()); assertTrue(client.getLocalAddress().getPort() > 0); assertNotEquals(occupied.getLocalPort(), client.getLocalAddress().getPort()); - assertTrue(NET.udpEndpoint(NS_CLIENT).isOpen()); + assertTrue(pair.clientTransport.getUdpEndpoint().isOpen()); } } @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { - NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); - assertNotNull(NET.receiveLoopbackPacket(NS_SERVER)); - NET.sendConnectionlessPacket(NS_SERVER, new netadr_t(), ConnectionlessCommand.client_connect, ""); - NET.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); - NET.clearLoopbacks(); - assertNull(NET.receiveLoopbackPacket(NS_CLIENT)); - assertNull(NET.receiveLoopbackPacket(NS_SERVER)); + pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + assertNotNull(pair.server.receive()); + pair.server.sendConnectionless(new netadr_t(), ConnectionlessCommand.client_connect, ""); + pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + pair.clearLocalTraffic(); + assertNull(pair.client.receive()); + assertNull(pair.server.receive()); } } diff --git a/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java new file mode 100644 index 00000000..ab9decb4 --- /dev/null +++ b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java @@ -0,0 +1,25 @@ +package jake2.qcommon.network; + +import jake2.qcommon.network.runtime.NetworkRole; +import jake2.qcommon.network.runtime.NetworkTransport; +import jake2.qcommon.network.runtime.PacketNetwork; +import jake2.qcommon.network.transport.LoopbackLink; + +/** Private networking resources for one regression fixture; no process-wide role owners. */ +final class OwnedNetworkPair implements AutoCloseable { + private final LoopbackLink link = new LoopbackLink(); + final NetworkTransport clientTransport = new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()); + final NetworkTransport serverTransport = new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()); + final PacketNetwork client = new PacketNetwork(clientTransport, NetworkRole.CLIENT); + final PacketNetwork server = new PacketNetwork(serverTransport, NetworkRole.SERVER); + + void clearLocalTraffic() { + link.getFirst().clearIncoming(); + link.getSecond().clearIncoming(); + } + + @Override + public void close() { + try { client.close(); } finally { server.close(); } + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index ea766438..c9642dc7 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -34,6 +34,7 @@ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNe override fun stop() = packets.stop() override fun close() = packets.close() + /** Dedicated tick wait: currently a timed sleep, not socket-readiness waiting. */ override fun sleep(millis: Int) { if (transport.udpEndpoint == null) return try { From 9803f36cc4bcf8a029ef824ada1151c8a2e0223a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 16:39:35 +0100 Subject: [PATCH 34/57] Review network message hierarchy and Kotlin migration risks --- .../jake2/server/CakeHostApplicationTest.kt | 15 +++ .../src/main/kotlin/org/demoth/cake/Cake.kt | 15 ++- info/Networking.md | 10 +- .../network/messages/NetworkPacket.java | 60 ++------- .../messages/client/ClientMessage.java | 16 ++- .../messages/client/ClientMessageType.java | 2 +- .../messages/server/ConfigStringMessage.java | 4 + .../messages/server/DownloadMessage.java | 4 +- .../messages/server/FrameHeaderMessage.java | 6 + .../messages/server/MuzzleFlash2Message.java | 1 + .../server/PacketEntitiesMessage.java | 6 +- .../messages/server/ServerMessage.java | 29 +++- .../messages/server/ServerMessageType.java | 4 +- .../network/messages/server/SoundMessage.java | 4 +- .../messages/server/WeaponSoundMessage.java | 1 + .../main/java/jake2/qcommon/sizebuf_t.java | 3 +- .../network/protocol/MessageBodyCodec.kt | 43 ++++++ .../network/protocol/MessageDecodeResult.kt | 12 ++ .../qcommon/network/protocol/MessageReader.kt | 63 +++++++++ .../network/protocol/MessageBodyCodecTest.kt | 124 ++++++++++++++++++ .../network/protocol/PacketCodecTest.kt | 2 +- .../network/protocol/ReliableChannelTest.kt | 2 +- .../network/runtime/OwnedChannelTest.kt | 4 +- .../main/kotlin/jake2/server/ServerHost.kt | 16 ++- .../kotlin/jake2/server/ServerNetworkTest.kt | 39 ++++++ 25 files changed, 408 insertions(+), 77 deletions(-) create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index 4cac7143..a1d8f57c 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -17,6 +17,7 @@ import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.network.Netchan import jake2.qcommon.network.transport.LoopbackLink import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.network.protocol.PacketCodec import org.demoth.cake.Cake import org.demoth.cake.ui.menu.MenuBackend import org.demoth.cake.ui.menu.HostGameFormState @@ -151,6 +152,20 @@ class CakeHostApplicationTest { cake!!.keyUp(Input.Keys.GRAVE) cake!!.render() assertEquals(frame, gameFrame(host), "The console pauses singleplayer") + } + 6 -> { + cake!!.render() + assertTrue(clientState(cake!!) in setOf("CONNECTED", "ACTIVE")) + val malformed = PacketCodec.encodeSequenced(1_000_000, 0, null, + PacketBytes.copyOf(byteArrayOf(16, -2, -1, 0))) + localLink.second.send(localLink.first.localAddress, malformed) + } + 7 -> { + cake!!.render() + assertEquals("DISCONNECTED", clientState(cake!!)) + assertEquals(HostState.STOPPED, host.state) + assertSame(initialHost, host.server) + assertNotNull(Cmd.FindCommand("map"), "Malformed server input must leave the application Host usable") Gdx.app.exit() } } 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 cc32ce24..9e11865e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -24,6 +24,7 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import org.demoth.cake.network.ClientNetwork import org.demoth.cake.network.ClientHandshake +import jake2.qcommon.network.protocol.MessageDecodeResult import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand @@ -1080,7 +1081,19 @@ class Cake( if (netchan.accept(networkPacket)) { networkDebugSampler.recordInbound(netchan.last_received_size, Globals.curtime) - parseServerMessage(networkPacket.parseBodyFromServer()) + when (val body = networkPacket.parseBodyFromServer()) { + is MessageDecodeResult.Decoded -> { + if (Netchan.printNetwork?.typedValue == true) body.messages.forEach { message -> + if (message !is FrameHeaderMessage && message !is PlayerInfoMessage && message !is PacketEntitiesMessage) + Com.Printf("Network: Received server msg: $message\n") + } + parseServerMessage(body.messages) + } + is MessageDecodeResult.Rejected -> { + Com.Warn("Bad server payload at ${body.offset}: ${body.detail}\n") + dropToConsole() + } + } } //else wasn't accepted for some reason diff --git a/info/Networking.md b/info/Networking.md index 2ad9f69a..2f91f6fb 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -21,7 +21,15 @@ readiness. Network ownership does not impose a global client/server sleep policy ## NetworkPacket Every frame both client and servers are sending a jake2.qcommon.network.messages.NetworkPacket to each other. -Each such packet contains a header and several (Client|Server)Messages +Each such packet contains a header and several (Client|Server)Messages. + +`MessageBodyCodec` decodes a sequenced payload with bounded reads before controllers +receive any messages. It returns either the complete collection or a typed rejection, +so a valid prefix of a structurally malformed packet cannot trigger application commands. Lengths, +opcodes, directions, and table indices are checked without global teardown from codecs. +The server drops the offending connection; the client returns to its console on a bad +server payload. Movement checksum validity keeps its existing `MoveMessage.valid`/controller policy. +Legacy file/demo readers keep their existing `sizebuf_t` behavior. ## "Connectionless" (Out of band) packet Such packets/messages are sent ad-hoc and don't require a running map instance, therefore could be sent by not connected clients. diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index fd768b41..566edb90 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -1,21 +1,15 @@ package jake2.qcommon.network.messages; -import jake2.qcommon.Com; -import jake2.qcommon.Defines; -import jake2.qcommon.network.Netchan; import jake2.qcommon.network.NetAddrType; import jake2.qcommon.network.protocol.PacketEnvelope; import jake2.qcommon.network.transport.NetworkAddress; import jake2.qcommon.network.messages.client.ClientMessage; -import jake2.qcommon.network.messages.client.EndOfClientPacketMessage; -import jake2.qcommon.network.messages.client.MoveMessage; -import jake2.qcommon.network.messages.server.*; +import jake2.qcommon.network.messages.server.ServerMessage; +import jake2.qcommon.network.protocol.MessageDecodeResult; +import jake2.qcommon.network.protocol.MessageBodyCodec; import jake2.qcommon.network.netadr_t; import jake2.qcommon.sizebuf_t; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; public class NetworkPacket { private PacketEnvelope envelope; @@ -77,50 +71,14 @@ public PacketEnvelope.Sequenced sequencedEnvelope() { throw new IllegalStateException("Connectionless packet has no channel header"); } - public Collection parseBodyFromServer() { - List result = new ArrayList<>(); - while (true) { - ServerMessage msg = ServerMessage.parseFromBuffer(buffer); - if (msg instanceof EndOfServerPacketMessage) { - break; - } else if (msg != null) { - if (!(msg instanceof FrameHeaderMessage) && !(msg instanceof PlayerInfoMessage) && !(msg instanceof PacketEntitiesMessage)) { - if (Netchan.printNetwork.getTypedValue()) { - Com.Printf("Network: Received server msg: " + msg + "\n"); - } - } - result.add(msg); - } - if (buffer.readcount > buffer.cursize) { - Com.Error(Defines.ERR_FATAL, "CL_ParseServerMessage: Bad server message:"); - break; - } - } - return result; + /** Decode the complete server payload before application dispatch; malformed input is an explicit rejection. */ + public MessageDecodeResult parseBodyFromServer() { + return MessageBodyCodec.decodeServer(envelope); } - public Collection parseBodyFromClient(int incomingSequence) { - List result = new ArrayList<>(); - while (true) { - ClientMessage msg = ClientMessage.parseFromBuffer(buffer, incomingSequence); - if (msg == null || msg instanceof EndOfClientPacketMessage) { - break; - } else { - if (!(msg instanceof MoveMessage)) { - if (Netchan.printNetwork.getTypedValue()) { - Com.Printf("Network: Received client msg: " + msg + "\n"); - } - } - result.add(msg); - } - - if (buffer.readcount > buffer.cursize) { - Com.Printf("SV_ReadClientMessage: bad read:\n"); - return null; - } - - } - return result; + /** Decode the complete client payload with the incoming sequence used by movement checksums. */ + public MessageDecodeResult parseBodyFromClient(int incomingSequence) { + return MessageBodyCodec.decodeClient(envelope, incomingSequence); } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java index e5bad31f..9005132d 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java @@ -2,6 +2,9 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; +import jake2.qcommon.network.protocol.MessageReadException; +import jake2.qcommon.network.protocol.MessageReader; +import jake2.qcommon.network.protocol.MessageRejection; public abstract class ClientMessage implements NetworkMessage { public final ClientMessageType type; @@ -18,7 +21,15 @@ public final void writeTo(sizebuf_t buffer) { protected abstract void writeProperties(sizebuf_t buffer); public static ClientMessage parseFromBuffer(sizebuf_t buffer, int incomingSequence) { - ClientMessageType type = ClientMessageType.fromByte((byte) buffer.readByte()); + if (!(buffer instanceof MessageReader)) { + MessageReader reader = MessageReader.fromBuffer(buffer); + try { return parseFromBuffer(reader, incomingSequence); } finally { buffer.readcount = reader.readcount; } + } + + if (buffer.readcount == buffer.cursize) return new EndOfClientPacketMessage(); + int opcode = buffer.readByte(); + ClientMessageType type = ClientMessageType.fromByte((byte) opcode); + if (type == null) throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unknown client opcode " + opcode); final ClientMessage msg; switch (type) { case CLC_BAD: @@ -38,8 +49,7 @@ public static ClientMessage parseFromBuffer(sizebuf_t buffer, int incomingSequen msg = new MoveMessage(incomingSequence); break; default: - // todo: notify somehow in case of unexpected type - msg = null; + throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unsupported client opcode " + opcode); } if (msg != null) { msg.parse(buffer); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessageType.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessageType.java index 97a0533a..6b51a956 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessageType.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessageType.java @@ -20,6 +20,6 @@ public static ClientMessageType fromByte(byte value) { if (cmd.value == value) return cmd; } - return CLC_BAD; + return null; } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java index 6827440f..5276a73b 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java @@ -1,6 +1,8 @@ package jake2.qcommon.network.messages.server; import jake2.qcommon.sizebuf_t; +import jake2.qcommon.network.protocol.MessageReadException; +import jake2.qcommon.network.protocol.MessageRejection; import static jake2.qcommon.Defines.*; @@ -27,6 +29,8 @@ protected void writeProperties(sizebuf_t buffer) { @Override public void parse(sizebuf_t buffer) { this.index = buffer.readShort(); + if (index < 0 || index >= MAX_CONFIGSTRINGS) + throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid configstring index " + index); this.config = buffer.readString(); } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java index d2436384..c851e86d 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java @@ -1,6 +1,7 @@ package jake2.qcommon.network.messages.server; import jake2.qcommon.sizebuf_t; +import jake2.qcommon.network.protocol.MessageReader; import java.util.Arrays; @@ -40,8 +41,9 @@ protected void writeProperties(sizebuf_t buffer) { public void parse(sizebuf_t buffer) { int size = buffer.readShort(); percentage = (byte) buffer.readByte(); - // fixme: signed or unsigned? + // Signed wire length: -1 is failure; retain the legacy -32640 failure marker for compatibility. if (size != -1 && size != -32640) { + MessageReader.requireRemaining(buffer, size); data = new byte[size]; buffer.readData(data, size); } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java index 34a6cca9..f19eaafa 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java @@ -1,6 +1,9 @@ package jake2.qcommon.network.messages.server; import jake2.qcommon.sizebuf_t; +import jake2.qcommon.network.protocol.MessageReader; +import jake2.qcommon.network.protocol.MessageReadException; +import jake2.qcommon.network.protocol.MessageRejection; import java.util.Arrays; @@ -54,6 +57,9 @@ public void parse(sizebuf_t buffer) { // fixme: do not read otherwise? suppressCount = buffer.readByte(); areaBitsLength = buffer.readByte(); + if (areaBitsLength < 0 || areaBitsLength > MAX_MAP_AREAS / 8) + throw new MessageReadException(MessageRejection.INVALID_LENGTH, buffer.readcount - 1, "Invalid area bits length " + areaBitsLength); + MessageReader.requireRemaining(buffer, areaBitsLength); areaBits = new byte[MAX_MAP_AREAS / 8]; buffer.readData(areaBits, areaBitsLength); } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java index b245dd3e..1daf4a61 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java @@ -29,6 +29,7 @@ protected void writeProperties(sizebuf_t buffer) { @Override public void parse(sizebuf_t buffer) { this.entityIndex = buffer.readShort(); + validateEntityIndex(buffer, entityIndex); this.flashType = buffer.readByte(); } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java index 1c22dbfe..039e3ee5 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java @@ -1,6 +1,8 @@ package jake2.qcommon.network.messages.server; import jake2.qcommon.*; +import jake2.qcommon.network.protocol.MessageReadException; +import jake2.qcommon.network.protocol.MessageRejection; import java.util.ArrayList; import java.util.List; @@ -50,10 +52,10 @@ public void parse(sizebuf_t buffer) { break; if (header.number >= Defines.MAX_EDICTS) - Com.Error(Defines.ERR_DROP, "CL_ParsePacketEntities: bad number:" + header.number); + throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "CL_ParsePacketEntities: bad number:" + header.number); if (buffer.readcount > buffer.cursize) - Com.Error(Defines.ERR_DROP, "CL_ParsePacketEntities: end of message"); + throw new MessageReadException(MessageRejection.TRUNCATED, buffer.readcount, "CL_ParsePacketEntities: end of message"); if ((header.flags & Defines.U_REMOVE) != 0) { diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index e603dd05..36579061 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -4,6 +4,9 @@ import jake2.qcommon.entity_state_t; import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; +import jake2.qcommon.network.protocol.MessageReadException; +import jake2.qcommon.network.protocol.MessageReader; +import jake2.qcommon.network.protocol.MessageRejection; /** * Common parent for all messages sent from server to client. @@ -41,9 +44,16 @@ protected static DeltaEntityHeader parseDeltaEntityHeader(sizebuf_t buffer) { else number = buffer.readByte(); + validateEntityIndex(buffer, number); return new DeltaEntityHeader(flags, number); } + /** Validate indices before a decoded message can address an engine entity table. */ + protected static void validateEntityIndex(sizebuf_t buffer, int number) { + if (number < 0 || number >= Defines.MAX_EDICTS) + throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number " + number); + } + /* * former CL_ParseDelta */ @@ -125,19 +135,28 @@ public final void writeTo(sizebuf_t buffer) { protected abstract void writeProperties(sizebuf_t buffer); public static ServerMessage parseFromBuffer(sizebuf_t buffer) { + if (!(buffer instanceof MessageReader)) { + MessageReader reader = MessageReader.fromBuffer(buffer); + try { return parseFromBuffer(reader); } finally { buffer.readcount = reader.readcount; } + } + + if (buffer.readcount == buffer.cursize) return new EndOfServerPacketMessage(); final int cmd = buffer.readByte(); if (cmd == -1) return new EndOfServerPacketMessage(); ServerMessageType type = ServerMessageType.fromInt(cmd); + if (type == null) throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unknown server opcode " + cmd); final ServerMessage msg; - // skip parsing of messages not yet migrated to the ServerMessage class + // Explicit end/no-op outcomes are distinct from unsupported wire opcodes. switch (type) { - case svc_bad: + case svc_end: + return new EndOfServerPacketMessage(); case svc_nop: + return null; + case svc_bad: default: - msg = null; - break; + throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode " + cmd); case svc_muzzleflash: msg = new WeaponSoundMessage(); break; @@ -204,7 +223,7 @@ public static ServerMessage parseFromBuffer(sizebuf_t buffer) { } else if (SplashTEMessage.SUBTYPES.contains(subtype)) { msg = new SplashTEMessage(subtype); } else { - throw new IllegalStateException("Unexpected temp entity type:" + subtype); + throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount - 1, "Unexpected temp entity type:" + subtype); } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessageType.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessageType.java index ef192024..847750e0 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessageType.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessageType.java @@ -54,10 +54,10 @@ public enum ServerMessageType { public static ServerMessageType fromInt(int type) { for (ServerMessageType t: values()) { - if (t.type == type) + if (t.type == type || (t == svc_end && type == 0xff)) return t; } - return svc_nop; + return null; } public final int type; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java index 97ee01d5..07da155d 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java @@ -1,6 +1,5 @@ package jake2.qcommon.network.messages.server; -import jake2.qcommon.Com; import jake2.qcommon.Defines; import jake2.qcommon.sizebuf_t; @@ -101,8 +100,7 @@ public void parse(sizebuf_t buffer) { if ((flags & Defines.SND_ENT) != 0) { // entity relative sendchan = buffer.readShort(); entityIndex = sendchan >> 3; - if (entityIndex > Defines.MAX_EDICTS) - Com.Error(Defines.ERR_DROP, "CL_ParseStartSoundPacket: ent = " + entityIndex); + validateEntityIndex(buffer, entityIndex); sendchan &= 7; } else { diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java index 655117f1..6c0dd293 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java @@ -33,6 +33,7 @@ protected void writeProperties(sizebuf_t buffer) { @Override public void parse(sizebuf_t buffer) { this.entityIndex = buffer.readShort(); + validateEntityIndex(buffer, entityIndex); this.type = buffer.readByte(); } diff --git a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java index 842491ca..b3b78edf 100644 --- a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java +++ b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java @@ -33,8 +33,9 @@ of the License, or (at your option) any later version. /** * sizebuf_t + * Network message codecs use the bounded MessageReader subclass; legacy readers keep sentinel outcomes. */ -public final class sizebuf_t { +public class sizebuf_t { public boolean allowoverflow = false; public boolean overflowed = false; public byte[] data = null; diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt new file mode 100644 index 00000000..e1689b3d --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt @@ -0,0 +1,43 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.client.* +import jake2.qcommon.network.messages.server.* + +/** + * Decode all payload messages before controllers dispatch any of them. No logging or teardown policy. + * Structural failures reject the packet; movement checksum validity retains its existing controller policy. + */ +object MessageBodyCodec { + @JvmStatic fun decodeServer(envelope: PacketEnvelope): MessageDecodeResult = + decode(envelope, PacketDirection.FROM_SERVER, ServerMessage::parseFromBuffer) { it is EndOfServerPacketMessage } + + @JvmStatic fun decodeClient(envelope: PacketEnvelope, sequence: Int): MessageDecodeResult = + decode(envelope, PacketDirection.FROM_CLIENT, { ClientMessage.parseFromBuffer(it, sequence) }) { it is EndOfClientPacketMessage } + + private fun decode( + envelope: PacketEnvelope, direction: PacketDirection, + parse: (MessageReader) -> T?, isEnd: (T) -> Boolean, + ): MessageDecodeResult { + if (envelope !is PacketEnvelope.Sequenced) + 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) + val messages = ArrayList() + return try { + while (reader.readcount < reader.cursize) { + val message = parse(reader) ?: continue // A valid server no-op carries no application data. + if (isEnd(message)) { + if (reader.readcount != reader.cursize) + throw MessageReadException(MessageRejection.INVALID_VALUE, reader.readcount, "Data after end marker") + break + } + messages.add(message) + } + MessageDecodeResult.Decoded(java.util.List.copyOf(messages)) + } catch (error: MessageReadException) { + MessageDecodeResult.Rejected(error.reason, error.offset, error.message ?: "Invalid message") + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt new file mode 100644 index 00000000..95535e66 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt @@ -0,0 +1,12 @@ +package jake2.qcommon.network.protocol + +/** Complete packet-body decode; rejected packets never publish a partial message collection. */ +sealed interface MessageDecodeResult { + data class Decoded(val messages: List) : MessageDecodeResult + data class Rejected(val reason: MessageRejection, val offset: Int, val detail: String) : MessageDecodeResult +} + +enum class MessageRejection { TRUNCATED, UNKNOWN_OPCODE, INVALID_LENGTH, INVALID_VALUE, WRONG_DIRECTION, NOT_SEQUENCED } + +/** Internal unwinding for legacy message parsers; the body codec converts it into a rejection. */ +class MessageReadException(val reason: MessageRejection, val offset: Int, detail: String) : RuntimeException(detail, null, false, false) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt new file mode 100644 index 00000000..f363b46a --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -0,0 +1,63 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines +import jake2.qcommon.Globals +import jake2.qcommon.sizebuf_t +import jake2.qcommon.network.transport.PacketBytes + +/** Owned packet bytes with bounded reads; legacy file/demo buffer readers retain their sentinel API. */ +class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { + init { + require(offset in 0..bytes.size) + data = bytes.toByteArray() + cursize = bytes.size + readcount = offset + } + + companion object { + /** Bridge direct legacy factory calls into bounded reads and let the factory copy back its cursor. */ + @JvmStatic fun fromBuffer(buffer: sizebuf_t): MessageReader { + requireRemaining(buffer, 0) + return MessageReader(PacketBytes.copyOf(buffer.data, buffer.cursize), buffer.readcount) + } + + /** Validate a wire length before allocation or copying, including when a legacy parser is called directly. */ + @JvmStatic fun requireRemaining(buffer: sizebuf_t, length: Int) { + if (length < 0) throw MessageReadException(MessageRejection.INVALID_LENGTH, buffer.readcount, "Negative length $length") + if (buffer.readcount < 0 || buffer.cursize > (buffer.data?.size ?: 0) || length > buffer.cursize - buffer.readcount) + throw MessageReadException(MessageRejection.TRUNCATED, buffer.readcount, "Need $length bytes") + } + } + + override fun readByte(): Int { + requireRemaining(this, 1) + return super.readByte() + } + override fun readSignedByte(): Byte { + requireRemaining(this, 1) + return super.readSignedByte() + } + override fun readShort(): Short { + requireRemaining(this, 2) + return super.readShort() + } + override fun readInt(): Int { + requireRemaining(this, 4) + return super.readInt() + } + + override fun readData(destination: ByteArray, length: Int) { + if (length < 0 || length > destination.size) + throw MessageReadException(MessageRejection.INVALID_LENGTH, readcount, "Invalid copy length $length") + requireRemaining(this, length) + System.arraycopy(data, readcount, destination, 0, length) + readcount += length + } + + override fun readDir(): FloatArray { + val index = readByte() + if (index !in 0 until Defines.NUMVERTEXNORMALS) + throw MessageReadException(MessageRejection.INVALID_VALUE, readcount - 1, "Invalid direction index $index") + return Globals.bytedirs[index].clone() + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt new file mode 100644 index 00000000..5684fef1 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt @@ -0,0 +1,124 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.messages.client.ClientMessage +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +/** Message decoding deliberately requires no engine, Cvar, or logger initialization. */ +class MessageBodyCodecTest { + private fun decode(bytes: ByteArray, client: Boolean = false): MessageDecodeResult<*> { + val wire = PacketCodec.encodeSequenced(1, 0, if (client) 7 else null, PacketBytes.copyOf(bytes)) + val envelope = (PacketCodec.decode(wire, if (client) PacketDirection.FROM_CLIENT else PacketDirection.FROM_SERVER) + as PacketDecodeResult.Decoded).envelope + return if (client) MessageBodyCodec.decodeClient(envelope, 1) else MessageBodyCodec.decodeServer(envelope) + } + + @ParameterizedTest(name = "{0} complete fixture and truncated prefixes") + @MethodSource("jake2.qcommon.network.messages.NetworkMessageSizeTest#createTestData") + fun `valid fixtures decode and every incomplete message prefix rejects`(name: String, message: NetworkMessage) { + val bytes = javaClass.getResourceAsStream("/jake2/qcommon/network/messages/$name.bin")!!.use { it.readBytes() } + val client = message is ClientMessage + assertInstanceOf(MessageDecodeResult.Decoded::class.java, decode(bytes, client), name) + assertInstanceOf(MessageDecodeResult.Decoded::class.java, decode(byteArrayOf(), client), "Header-only channel packets remain valid") + for (length in 1 until bytes.size) { + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(bytes.copyOf(length), client), "$name prefix $length") + } + } + + @Test + fun `reject malformed lengths and values without unchecked parser errors`() { + val badDownloads = listOf(-2, -32768, 32767) + for (length in badDownloads) { + val result = decode(byteArrayOf(16, length.toByte(), (length shr 8).toByte(), 0)) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, result) + } + // Missing inventory fields, oversized frame area bits, missing/invalid direction, entity bounds. + val frame = sizebuf_t(16).apply { + writeByte(20); writeInt(1); writeInt(0); writeByte(0); writeByte((Defines.MAX_MAP_AREAS / 8 + 1).toByte()) + } + val invalidSound = sizebuf_t(8).apply { + writeByte(9); writeByte(Defines.SND_ENT.toByte()); writeByte(1); writeShort(Defines.MAX_EDICTS shl 3) + } + val direction = sizebuf_t(16).apply { + writeByte(3); writeByte(Defines.TE_GUNSHOT.toByte()); writePos(floatArrayOf(0f, 0f, 0f)); writeByte(255.toByte()) + } + val entity = sizebuf_t(8).apply { + writeByte(18); writeByte(Defines.U_MOREBITS1.toByte()); writeByte((Defines.U_NUMBER16 shr 8).toByte()); writeShort(Defines.MAX_EDICTS) + } + assertEquals(MessageRejection.INVALID_VALUE, + (decode(entity.data.copyOf(entity.cursize)) as MessageDecodeResult.Rejected).reason) + for (body in listOf(byteArrayOf(5), byteArrayOf(3, 254.toByte()), frame.data.copyOf(frame.cursize), + invalidSound.data.copyOf(invalidSound.cursize), direction.data.copyOf(direction.cursize), entity.data.copyOf(entity.cursize))) { + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(body)) + } + } + + @Test + fun `entity and config indices cannot escape destination tables`() { + for (opcode in listOf(1, 2)) { + for (index in listOf(-1, Defines.MAX_EDICTS)) { + val bytes = byteArrayOf(opcode.toByte(), index.toByte(), (index shr 8).toByte(), 1) + assertEquals(MessageRejection.INVALID_VALUE, (decode(bytes) as MessageDecodeResult.Rejected).reason) + } + } + for (index in listOf(-1, Defines.MAX_CONFIGSTRINGS)) { + val bytes = byteArrayOf(13, index.toByte(), (index shr 8).toByte(), 0) + assertEquals(MessageRejection.INVALID_VALUE, (decode(bytes) as MessageDecodeResult.Rejected).reason) + } + } + + @Test + fun `known download failure markers and explicit packet endings remain supported`() { + for (length in listOf(-1, -32640)) { + val result = decode(byteArrayOf(16, length.toByte(), (length shr 8).toByte(), 0)) as MessageDecodeResult.Decoded + assertNull((result.messages.single() as DownloadMessage).data) + } + for (body in listOf(byteArrayOf(6), byteArrayOf(255.toByte()))) + assertTrue((decode(body) as MessageDecodeResult.Decoded).messages.isEmpty()) + assertTrue((decode(byteArrayOf(0), true) as MessageDecodeResult.Decoded).messages.isEmpty()) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(byteArrayOf(255.toByte(), 7))) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(byteArrayOf(0, 1), true)) + } + + @Test + fun `unknown opcode rejects the whole packet rather than publishing a valid prefix`() { + // disconnect prefix must not be delivered when followed by an unknown server opcode. + val server = decode(byteArrayOf(7, 254.toByte())) as MessageDecodeResult.Rejected + assertEquals(MessageRejection.UNKNOWN_OPCODE, server.reason) + // string command prefix must not execute when followed by an unknown client opcode. + val client = decode(byteArrayOf(4, 'x'.code.toByte(), 0, 99), true) as MessageDecodeResult.Rejected + assertEquals(MessageRejection.UNKNOWN_OPCODE, client.reason) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(byteArrayOf(254.toByte(), 7))) + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(byteArrayOf(99, 4, 'x'.code.toByte(), 0), true)) + } + + @Test + fun `legacy factory entry points also enforce bounds and never synthesize inventory data`() { + val raw = sizebuf_t(1).apply { writeByte(5) } + val error = assertThrows(MessageReadException::class.java) { ServerMessage.parseFromBuffer(raw) } + assertEquals(MessageRejection.TRUNCATED, error.reason) + assertTrue(raw.readcount <= raw.cursize) + } + + @Test + fun `repeat decoding uses owned packet bytes and direction mistakes reject explicitly`() { + val bytes = PacketCodec.encodeSequenced(1, 0, null, PacketBytes.copyOf(byteArrayOf(7))) + val envelope = (PacketCodec.decode(bytes, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + val packet = NetworkPacket.fromEnvelope(NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127,0,0,1)), 27910), envelope) + packet.buffer.data.fill(0) + assertEquals(packet.parseBodyFromServer(), packet.parseBodyFromServer()) + assertInstanceOf(DisconnectMessage::class.java, (packet.parseBodyFromServer() as MessageDecodeResult.Decoded).messages.single()) + assertEquals(MessageRejection.WRONG_DIRECTION, (packet.parseBodyFromClient(1) as MessageDecodeResult.Rejected).reason) + val connectionless = PacketBytes.copyOf(byteArrayOf(-1,-1,-1,-1) + "ping".toByteArray()) + val decoded = (PacketCodec.decode(connectionless, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + assertEquals(MessageRejection.NOT_SEQUENCED, (MessageBodyCodec.decodeServer(decoded) as MessageDecodeResult.Rejected).reason) + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt index 6894c26b..73521661 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -144,7 +144,7 @@ class PacketCodecTest { val envelope = decode(buffer.data.copyOf(buffer.cursize), PacketDirection.FROM_CLIENT) val packet = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), envelope) - val parsed = assertInstanceOf(MoveMessage::class.java, packet.parseBodyFromClient(sequence).single()) + val parsed = assertInstanceOf(MoveMessage::class.java, (packet.parseBodyFromClient(sequence) as MessageDecodeResult.Decoded).messages.single()) assertTrue(parsed.valid) assertEquals(12, parsed.lastReceivedFrame) assertEquals(120, parsed.newCmd.forwardmove.toInt()) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt index 00d271f8..c4908785 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt @@ -231,7 +231,7 @@ class ReliableChannelTest { client.completeSend(packet, true, 10) val legacy = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), envelope(packet, PacketDirection.FROM_CLIENT)) - assertTrue((legacy.parseBodyFromClient(1).single() as MoveMessage).valid) + assertTrue(((legacy.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as MoveMessage).valid) } private fun bytes(vararg values: Int) = PacketBytes.copyOf(ByteArray(values.size) { values[it].toByte() }) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt index ac11c2b3..e439e5c6 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -46,8 +46,8 @@ class OwnedChannelTest { val secondPacket = second.receive(second.serverTransport, PacketDirection.FROM_CLIENT) assertTrue(first.server.accept(firstPacket)) assertTrue(second.server.accept(secondPacket)) - assertEquals("first", (firstPacket.parseBodyFromClient(1).single() as StringCmdMessage).command) - assertEquals("second", (secondPacket.parseBodyFromClient(1).single() as StringCmdMessage).command) + assertEquals("first", ((firstPacket.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as StringCmdMessage).command) + assertEquals("second", ((secondPacket.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as StringCmdMessage).command) assertTrue(first.server.transmit(null)) assertTrue(first.client.accept(first.receive(first.clientTransport, PacketDirection.FROM_SERVER))) assertTrue(first.client.canReliable()) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index df7d5a35..9ff26317 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -24,6 +24,8 @@ package jake2.server import jake2.qcommon.* import jake2.qcommon.exec.* import jake2.qcommon.exec.CvarTypes.ChoiceType +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.Netchan import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage @@ -1161,8 +1163,18 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (client.state != ClientStates.CS_ZOMBIE) { client.lastmessage = realtime val body = networkPacket.parseBodyFromClient(client.netchan.incoming_sequence) - if (body == null) dropClient(client) - else handleClientMessages(client, body) + when (body) { + is MessageDecodeResult.Rejected -> { + Com.Printf("Bad client payload at ${body.offset}: ${body.detail}\n") + dropClient(client) + } + is MessageDecodeResult.Decoded -> { + if (Netchan.printNetwork?.typedValue == true) body.messages.forEach { message -> + if (message !is MoveMessage) Com.Printf("Network: Received client msg: $message\n") + } + handleClientMessages(client, body.messages) + } + } } } } diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt index 7e82eb6a..cb28d22c 100644 --- a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -6,6 +6,12 @@ import jake2.qcommon.network.Netchan import jake2.qcommon.network.transport.* import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.runtime.PacketNetwork +import jake2.qcommon.network.protocol.PacketCodec +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.client.UserInfoMessage +import jake2.qcommon.network.messages.server.DisconnectMessage +import jake2.qcommon.Defines +import jake2.qcommon.sizebuf_t import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -38,6 +44,39 @@ class ServerNetworkTest { } } + @Test + fun `malformed client body drops only that client without dispatching its valid prefix`() { + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + try { + Peer().use { peer -> + fun connection(qport: Int) = ClientNetworkInfo(peer.network.newChannel()).apply { + state = ClientStates.CS_CONNECTED + userinfo = "before" + netchan.setup(Defines.NS_SERVER, netadr_t(), qport) + } + val bad = connection(7) + val healthy = connection(8) + peer.host.clients.addAll(listOf(bad, healthy)) + val payload = sizebuf_t(64).apply { + UserInfoMessage("\\name\\must_not_apply").writeTo(this) + writeByte(99) + } + val bytes = PacketCodec.encodeSequenced(1, 0, 7, PacketBytes.copyOf(payload.data, payload.cursize)) + peer.client.send(peer.client.localPeer, bytes) + peer.host.readPackets() + assertEquals(ClientStates.CS_ZOMBIE, bad.state) + assertEquals("before", bad.userinfo) + assertTrue(bad.netchan.reliablePending.any { it is DisconnectMessage }) + assertEquals(ClientStates.CS_CONNECTED, healthy.state) + assertTrue(healthy.netchan.reliablePending.isEmpty()) + } + } finally { + Cvar.getInstance().clear() + } + } + @Test fun `Host packet pass bounds malformed and valid traffic and resumes next pass`() { Cvar.getInstance().clear() From ef4dff945931880f4116bc628dbc05b553097a6c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 19:52:27 +0100 Subject: [PATCH 35/57] Preserve entity and sound wire values during re-encoding --- .../java/jake2/server/GameImportsTest.java | 3 +- .../main/java/jake2/qcommon/DeltaUtils.java | 70 +++++----- .../server/PacketEntitiesMessage.java | 38 ++---- .../network/messages/server/SoundMessage.java | 30 +++-- .../messages/server/SpawnBaselineMessage.java | 2 +- .../network/protocol/MessageReencodingTest.kt | 121 ++++++++++++++++++ 6 files changed, 189 insertions(+), 75 deletions(-) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 83179aca..b93708d5 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -255,7 +255,8 @@ public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { Assertions.assertArrayEquals(new float[]{42, 0, 0}, ((SoundMessage) sounds.get(1)).origin); Assertions.assertEquals(Defines.SND_ENT | Defines.SND_POS | Defines.SND_VOLUME | Defines.SND_ATTENUATION | Defines.SND_OFFSET, earlier.flags); - Assertions.assertEquals((entity.index << 3) | Defines.CHAN_WEAPON, earlier.sendchan); + Assertions.assertEquals(entity.index, earlier.entityIndex); + Assertions.assertEquals(Defines.CHAN_WEAPON, earlier.sendchan); float[] explicitOrigin = {1, 2, 3}; first.positioned_sound(explicitOrigin, entity, Defines.CHAN_RELIABLE | Defines.CHAN_VOICE, diff --git a/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java b/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java index 3a8ef16d..9513eaff 100644 --- a/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java +++ b/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java @@ -43,25 +43,12 @@ public static void WriteDeltaEntity(entity_state_t from, entity_state_t to, if (deltaFlags == 0 && !force) return; // nothing to send! - buffer.writeByte((byte) (deltaFlags & 255)); - - if ((deltaFlags & 0xff000000) != 0) { - buffer.writeByte((byte) (deltaFlags >>> 8 & 255)); - buffer.writeByte((byte) (deltaFlags >>> 16 & 255)); - buffer.writeByte((byte) (deltaFlags >>> 24 & 255)); - } else if ((deltaFlags & 0x00ff0000) != 0) { - buffer.writeByte((byte) (deltaFlags >>> 8 & 255)); - buffer.writeByte((byte) (deltaFlags >>> 16 & 255)); - } else if ((deltaFlags & 0x0000ff00) != 0) { - buffer.writeByte((byte) (deltaFlags >>> 8 & 255)); - } - - //---------- + writeEntityDelta(deltaFlags, to.index, to, buffer); + } - if ((deltaFlags & U_NUMBER16) != 0) - buffer.writeShort(to.index); - else - buffer.writeByte((byte) to.index); + /** Write fields selected by the wire mask, including explicitly transmitted zero values in received deltas. */ + public static void writeEntityDelta(int deltaFlags, int number, entity_state_t to, sizebuf_t buffer) { + writeEntityHeader(deltaFlags, number, buffer); if ((deltaFlags & U_MODEL) != 0) buffer.writeByte((byte) to.modelindex); @@ -127,6 +114,24 @@ else if ((deltaFlags & U_RENDERFX16) != 0) buffer.writeShort(to.solid); } + /** Emit the flag-extension bytes and entity number exactly as described by the wire flags. */ + public static void writeEntityHeader(int flags, int number, sizebuf_t buffer) { + buffer.writeByte((byte) flags); + if ((flags & U_MOREBITS1) != 0) buffer.writeByte((byte) (flags >>> 8)); + if ((flags & U_MOREBITS2) != 0) buffer.writeByte((byte) (flags >>> 16)); + if ((flags & U_MOREBITS3) != 0) buffer.writeByte((byte) (flags >>> 24)); + if ((flags & U_NUMBER16) != 0) buffer.writeShort(number); + else buffer.writeByte((byte) number); + } + + /** Size of the wire header, including requested extension bytes even if their value is zero. */ + public static int getEntityHeaderSize(int flags) { + return 1 + ((flags & U_MOREBITS1) != 0 ? 1 : 0) + + ((flags & U_MOREBITS2) != 0 ? 1 : 0) + + ((flags & U_MOREBITS3) != 0 ? 1 : 0) + + ((flags & U_NUMBER16) != 0 ? 2 : 1); + } + private static int getDeltaFlags(entity_state_t from, entity_state_t to, boolean newentity) { if (0 == to.index) Com.Error(ERR_FATAL, "Unset entity number"); @@ -221,26 +226,19 @@ else if ((deltaFlags & 0x0000ff00) != 0) } public static int getDeltaSize(entity_state_t from, entity_state_t to, boolean newentity) { - int flags = getDeltaFlags(from, to, newentity); - if (flags == 0) - return 0; - - int result = 1; - - if ((flags & 0xff000000) != 0) { - result += 3; - } else if ((flags & 0x00ff0000) != 0) { - result += 2; - } else if ((flags & 0x0000ff00) != 0) { - result += 1; - } + return getDeltaSize(from, to, false, newentity); + } - //---------- + /** Match WriteDeltaEntity's force rule: unchanged forced updates still carry a header. */ + public static int getDeltaSize(entity_state_t from, entity_state_t to, boolean force, boolean newentity) { + int flags = getDeltaFlags(from, to, newentity); + if (flags == 0 && !force) return 0; + return getEntityDeltaSize(flags); + } - if ((flags & U_NUMBER16) != 0) - result += 2; - else - result +=1; + /** Size a received delta directly from its field mask rather than recomputing baseline differences. */ + public static int getEntityDeltaSize(int flags) { + int result = getEntityHeaderSize(flags); if ((flags & U_MODEL) != 0) result += 1; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java index 039e3ee5..89d2a19f 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java @@ -21,22 +21,15 @@ public PacketEntitiesMessage() { @Override protected void writeProperties(sizebuf_t buffer) { for (EntityUpdate u : updates) { - // During serialization we don't have a header for entity state changes + // Outbound changes calculate their header from baseline/current state. if (u.header == null) { - // entity is changed DeltaUtils.WriteDeltaEntity(u.oldState, u.newState, buffer, u.force, u.isNewEntity); + } else if ((u.header.flags & Defines.U_REMOVE) != 0) { + // Entity removal carries only a header (former entity bits). + DeltaUtils.writeEntityHeader(u.header.flags, u.header.number, buffer); } else { - // entity is removed - buffer.writeByte((byte) (u.header.flags & 255)); - - if ((u.header.flags & 0x0000ff00) != 0) - buffer.writeByte((byte) (u.header.flags >> 8 & 255)); - - if ((u.header.flags & Defines.U_NUMBER16) != 0) - buffer.writeShort(u.header.number); - else - buffer.writeByte((byte) u.header.number); - + // Received changes already have a wire header; preserve its field presence and values. + DeltaUtils.writeEntityDelta(u.header.flags, u.header.number, u.newState, buffer); } } // end of packetentities @@ -74,20 +67,13 @@ public int getSize() { private int getUpdateSize(EntityUpdate value) { if (value.header == null) { - // entity is changed - return DeltaUtils.getDeltaSize(value.oldState, value.newState, value.isNewEntity); + // Outbound change, including the forced unchanged-header case. + return DeltaUtils.getDeltaSize(value.oldState, value.newState, value.force, value.isNewEntity); + } else if ((value.header.flags & Defines.U_REMOVE) != 0) { + // Entity removal has no payload. + return DeltaUtils.getEntityHeaderSize(value.header.flags); } else { - // entity is removed - int result = 1; - if ((value.header.flags & 0x0000ff00) != 0) - result += 1; - - if ((value.header.flags & Defines.U_NUMBER16) != 0) - result += 2; - else - result += 1; - - return result; + return DeltaUtils.getEntityDeltaSize(value.header.flags); } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java index 07da155d..78127caf 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java @@ -20,7 +20,7 @@ * An attenuation of 0 will play full volume everywhere in the level. * Larger attenuation will drop off. (max 4 attenuation) *

- * Timeofs can range from 0.0 to 0.1 to cause sounds to be started + * Timeofs can range from 0.0 to 0.255 to cause sounds to be started * later in the frame than they normally would. *

* If origin is null, the origin is determined from the entity origin @@ -32,7 +32,14 @@ public class SoundMessage extends ServerMessage { public int soundIndex; public float volume; public float attenuation; - public float timeOffset; + /** Integer wire milliseconds avoid losing a received value when converting through float seconds. */ + private int timeOffsetMillis; + + public float getTimeOffset() { return timeOffsetMillis / 1000.0f; } + + /** Outbound sounds retain the protocol's existing truncation from seconds to milliseconds. */ + public void setTimeOffset(float seconds) { timeOffsetMillis = (int) (seconds * 1000); } + /** Logical channel (0..7); the wire packs entityIndex in the upper bits. */ public int sendchan; public float[] origin; @@ -48,8 +55,9 @@ public SoundMessage(int flags, int soundIndex, float volume, float attenuation, this.soundIndex = soundIndex; this.volume = volume; this.attenuation = attenuation; - this.timeOffset = timeOffset; - this.sendchan = sendchan; + setTimeOffset(timeOffset); + this.entityIndex = (flags & Defines.SND_ENT) != 0 ? sendchan >> 3 : 0; + this.sendchan = (flags & Defines.SND_ENT) != 0 ? sendchan & 7 : 0; this.origin = origin; } @@ -67,10 +75,10 @@ protected void writeProperties(sizebuf_t buffer) { buffer.writeByte((byte) (attenuation * 64)); if ((flags & Defines.SND_OFFSET) != 0) - buffer.writeByte((byte) (timeOffset * 1000)); + buffer.writeByte((byte) timeOffsetMillis); if ((flags & Defines.SND_ENT) != 0) - buffer.writeShort(sendchan); + buffer.writeShort((entityIndex << 3) | (sendchan & 7)); if ((flags & Defines.SND_POS) != 0) buffer.writePos(origin); @@ -93,9 +101,9 @@ public void parse(sizebuf_t buffer) { attenuation = Defines.DEFAULT_SOUND_PACKET_ATTENUATION; if ((flags & Defines.SND_OFFSET) != 0) - timeOffset = buffer.readByte() / 1000.0f; + timeOffsetMillis = buffer.readByte(); else - timeOffset = 0; + timeOffsetMillis = 0; if ((flags & Defines.SND_ENT) != 0) { // entity relative sendchan = buffer.readShort(); @@ -150,7 +158,7 @@ public boolean equals(Object o) { if (soundIndex != that.soundIndex) return false; if (Float.compare(that.volume, volume) != 0) return false; if (Float.compare(that.attenuation, attenuation) != 0) return false; - if (Float.compare(that.timeOffset, timeOffset) != 0) return false; + if (timeOffsetMillis != that.timeOffsetMillis) return false; if (sendchan != that.sendchan) return false; if (entityIndex != that.entityIndex) return false; return Arrays.equals(origin, that.origin); @@ -162,7 +170,7 @@ public int hashCode() { result = 31 * result + soundIndex; result = 31 * result + (volume != +0.0f ? Float.floatToIntBits(volume) : 0); result = 31 * result + (attenuation != +0.0f ? Float.floatToIntBits(attenuation) : 0); - result = 31 * result + (timeOffset != +0.0f ? Float.floatToIntBits(timeOffset) : 0); + result = 31 * result + timeOffsetMillis; result = 31 * result + sendchan; result = 31 * result + Arrays.hashCode(origin); result = 31 * result + entityIndex; @@ -176,7 +184,7 @@ public String toString() { ", soundIndex=" + soundIndex + ", volume=" + volume + ", attenuation=" + attenuation + - ", timeOffset=" + timeOffset + + ", timeOffset=" + getTimeOffset() + ", sendchan=" + sendchan + ", origin=" + Arrays.toString(origin) + ", entityIndex=" + entityIndex + diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java index 3d1c24d2..cc8d5895 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java @@ -37,7 +37,7 @@ public void parse(sizebuf_t buffer) { @Override public int getSize() { - return 1 + DeltaUtils.getDeltaSize(base, entityState, true); + return 1 + DeltaUtils.getDeltaSize(base, entityState, true, true); } @Override diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt new file mode 100644 index 00000000..388cbd3b --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt @@ -0,0 +1,121 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MessageReencodingTest { + private fun write(message: NetworkMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(message.size, buffer.cursize, "Declared size must fit an exactly sized buffer") + return buffer.data.copyOf(buffer.cursize) + } + private fun parse(bytes: ByteArray): ServerMessage { + val buffer = sizebuf_t(bytes.size).apply { writeBytes(bytes) } + val result = ServerMessage.parseFromBuffer(buffer)!! + assertEquals(buffer.cursize, buffer.readcount) + return result + } + private fun roundTrip(message: ServerMessage) { + val bytes = write(message) + assertArrayEquals(bytes, write(parse(bytes))) + } + + @Test + fun `forced unchanged updates fit their declared size across entity number widths`() { + for (index in listOf(1, 255, 256, MAX_EDICTS - 1)) { + for (force in listOf(false, true)) for (isNew in listOf(false, true)) { + val message = PacketEntitiesMessage().apply { + updates.add(EntityUpdate(entity_state_t(index), entity_state_t(index), force, isNew)) + } + if (index < 256 && !isNew) assertEquals(if (force) 5 else 3, message.size) + roundTrip(message) + } + } + } + + @Test + fun `changed and removed entities retain payload values and flag widths`() { + val changes: List<(entity_state_t) -> Unit> = listOf( + { it.modelindex = 7; it.modelindex2 = 8; it.modelindex3 = 9; it.modelindex4 = 10 }, + { it.frame = 255 }, { it.frame = 256 }, { it.frame = 65535 }, + { it.skinnum = 255 }, { it.skinnum = 65535 }, { it.skinnum = 0x12345678 }, + { it.effects = 255 }, { it.effects = 32767 }, { it.effects = 0x12345678 }, + { it.renderfx = 255 }, { it.renderfx = 32767 }, { it.renderfx = 0x12345678 }, + { it.origin = floatArrayOf(-1.125f, 2.25f, 3.5f) }, + { it.angles = floatArrayOf(-90f, 45f, 178.59375f) }, + { it.old_origin = floatArrayOf(1.125f, 2.25f, 3.5f); it.renderfx = RF_BEAM }, + { it.sound = 255; it.event = 255; it.solid = 65535 }, + ) + for (index in listOf(1, 256, MAX_EDICTS - 1)) for (change in changes) { + val current = entity_state_t(index).also(change) + val message = PacketEntitiesMessage().apply { + updates.add(EntityUpdate(entity_state_t(index), current, false, true)) + val flags = U_REMOVE or if (index >= 256) U_NUMBER16 or U_MOREBITS1 else 0 + updates.add(EntityUpdate(DeltaEntityHeader(flags, index))) + } + roundTrip(message) + val parsed = parse(write(message)) as PacketEntitiesMessage + assertNotNull(parsed.updates[0].newState) + assertNull(parsed.updates[1].newState) + } + } + + @Test + fun `explicit zero fields and zero-valued flag extensions survive re-encoding`() { + val old = entity_state_t(1).apply { modelindex = 5; origin[0] = 10f } + val message = PacketEntitiesMessage().apply { + updates.add(EntityUpdate(old, entity_state_t(1), false, false)) + } + val decoded = parse(write(message)) as PacketEntitiesMessage + assertTrue(decoded.updates.single().header.flags and U_MODEL != 0) + assertEquals(0, decoded.updates.single().newState.modelindex) + roundTrip(message) + for (bytes in listOf( + byteArrayOf(18, 128.toByte(), 0, 1, 0, 0), + byteArrayOf(18, 128.toByte(), 128.toByte(), 128.toByte(), 0, 1, 0, 0), + )) assertArrayEquals(bytes, write(parse(bytes))) + } + + @Test + fun `sound constructor and parser use the same entity and channel representation`() { + for (flags in 0..31) for (index in listOf(0, 3, MAX_EDICTS - 1)) for (channel in 0..7) { + val message = SoundMessage(flags, 2, 1f, 2f, 0.2f, (index shl 3) or channel, + floatArrayOf(1f, -2f, 3f)) + val decoded = parse(write(message)) as SoundMessage + assertEquals(if (flags and SND_ENT != 0) index else 0, decoded.entityIndex) + assertEquals(if (flags and SND_ENT != 0) channel else 0, decoded.sendchan) + roundTrip(message) + } + } + + @Test + fun `outbound sound delay retains truncation and edited decoded delays use the same rule`() { + val created = SoundMessage(SND_OFFSET, 2, 1f, 1f, 0.251f, 0, null) + val expected = (0.251f * 1000).toInt() + assertEquals(expected / 1000f, created.timeOffset) + assertEquals(expected.toByte(), write(created).last()) + val decoded = parse(byteArrayOf(9, SND_OFFSET.toByte(), 2, 251.toByte())) as SoundMessage + assertEquals(0.251f, decoded.timeOffset) + assertEquals(251.toByte(), write(decoded).last()) + decoded.timeOffset = 0.1239f + assertEquals(123.toByte(), write(decoded).last()) + } + + @Test + fun `every quantized sound scalar byte survives decode and re-encode`() { + for (value in 0..255) { + val buffer = sizebuf_t(8).apply { + writeByte(9); writeByte((SND_VOLUME or SND_ATTENUATION or SND_OFFSET or SND_ENT).toByte()) + writeByte(2); writeByte(value.toByte()); writeByte(value.toByte()); writeByte(value.toByte()) + writeShort((123 shl 3) or 7) + } + assertArrayEquals(buffer.data, write(parse(buffer.data)), "Scalar wire value $value") + } + } +} From ab25efe54652e7548f1d98b76d03653b5ea556a2 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 20:51:48 +0100 Subject: [PATCH 36/57] Decode payload strings with the protocol Latin-1 charset --- .../main/java/jake2/qcommon/sizebuf_t.java | 9 +- .../messages/NetworkMessageSizeTest.java | 2 + .../protocol/MessageStringEncodingTest.kt | 84 ++++++++++++++++++ .../client.StringCmdMessage.highbit.bin | Bin 0 -> 13 bytes .../messages/server.PrintMessage.highbit.bin | Bin 0 -> 10 bytes 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt create mode 100644 qcommon/src/test/resources/jake2/qcommon/network/messages/client.StringCmdMessage.highbit.bin create mode 100644 qcommon/src/test/resources/jake2/qcommon/network/messages/server.PrintMessage.highbit.bin diff --git a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java index b3b78edf..2fda7381 100644 --- a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java +++ b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java @@ -28,6 +28,7 @@ of the License, or (at your option) any later version. import jake2.qcommon.util.Lib; import jake2.qcommon.util.Math3D; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; @@ -168,7 +169,9 @@ public void writeString(String s) { } /** - * Read at most 2048 single byte characters + * Read at most 2047 single byte characters using the writer's Latin-1 encoding. + * NUL and the legacy 0xff sentinel terminate a string; embedded newlines are payload. + * The bounded network reader rejects underruns; legacy readers retain their sentinel outcome. */ public String readString() { // 2k read buffer. @@ -184,7 +187,7 @@ public String readString() { l++; } while (l < 2047); - return new String(readbuf, 0, l); + return new String(readbuf, 0, l, StandardCharsets.ISO_8859_1); } public void writeCoord(float f) { @@ -291,4 +294,4 @@ public void clear() { overflowed = false; } -} \ No newline at end of file +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index ea426382..23a46e31 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -47,6 +47,7 @@ public static Stream createTestData() { testMessages.add(new Object[]{"server.DisconnectMessage", new DisconnectMessage()}); testMessages.add(new Object[]{"server.ReconnectMessage", new ReconnectMessage()}); testMessages.add(new Object[]{"server.LayoutMessage", new LayoutMessage("layout")}); + testMessages.add(new Object[]{"server.PrintMessage.highbit", new PrintMessage(2, "caf\u00e9\n\u0080\u00fe")}); testMessages.add(new Object[]{"server.ConfigStringMessage", new ConfigStringMessage(4, "config")}); // expect only PROTOCOL_VERSION(34) testMessages.add(new Object[]{"server.ServerDataMessage", new ServerDataMessage(PROTOCOL_VERSION, 3, false, "hello quake", 1, "q3dm6")}); @@ -132,6 +133,7 @@ public static Stream createTestData() { testMessages.add(new Object[]{"client.NoopMessage", new NoopMessage()}); testMessages.add(new Object[]{"client.UserInfoMessage", new UserInfoMessage("test/user/info")}); testMessages.add(new Object[]{"client.StringCmdMessage", new StringCmdMessage("test command")}); + testMessages.add(new Object[]{"client.StringCmdMessage.highbit", new StringCmdMessage("say caf\u00e9\n\u0080\u00fe")}); // delta compressed // empty one testMessages.add(new Object[]{"client.MoveMessage.empty", new MoveMessage(false, 1, new usercmd_t(), new usercmd_t(), new usercmd_t(), 1)}); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt new file mode 100644 index 00000000..6dad8f04 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt @@ -0,0 +1,84 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.PROTOCOL_VERSION +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.client.* +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MessageStringEncodingTest { + private fun write(message: NetworkMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(message.size, buffer.cursize) + return buffer.data.copyOf(buffer.cursize) + } + + @Test + fun `every payload string message retains all non-terminator Latin-1 bytes`() { + // NUL and 0xff are legacy terminators; all other bytes, including newlines, are payload. + val text = (1..254).map(Int::toChar).joinToString("") + val messages = listOf( + PrintMessage(2, text), PrintCenterMessage(text), LayoutMessage(text), + ConfigStringMessage(4, text), StuffTextMessage(text), + ServerDataMessage(PROTOCOL_VERSION, 3, false, text, 1, text), + StringCmdMessage(text), UserInfoMessage(text), + ) + for (message in messages) { + val bytes = write(message) + val buffer = sizebuf_t(bytes.size).apply { writeBytes(bytes) } + val decoded = if (message is ClientMessage) ClientMessage.parseFromBuffer(buffer, 1) + else ServerMessage.parseFromBuffer(buffer) + assertEquals(message, decoded, message.javaClass.simpleName) + assertEquals(buffer.cursize, buffer.readcount) + assertArrayEquals(bytes, write(decoded!!), message.javaClass.simpleName) + } + } + + @Test + fun `raw wire bytes decode as Latin-1 independently of the platform charset`() { + val bytes = byteArrayOf(0x63, 0x61, 0x66, 0xe9.toByte(), 10, 0x80.toByte(), 0xfe.toByte(), 0) + val expected = "caf\u00e9\n\u0080\u00fe" + val legacy = sizebuf_t(bytes.size).apply { writeBytes(bytes) } + val bounded = MessageReader(PacketBytes.copyOf(bytes), 0) + assertEquals(expected, legacy.readString()) + assertEquals(expected, bounded.readString()) + assertEquals(bytes.size, legacy.readcount) + assertEquals(bytes.size, bounded.readcount) + } + + @Test + fun `NUL and legacy ff terminate payload strings without consuming the following message`() { + for (terminator in listOf(0, 255)) { + val wire = byteArrayOf(10, 2, 0xe9.toByte(), terminator.toByte(), 7) + val reader = MessageReader(PacketBytes.copyOf(wire), 0) + assertEquals(PrintMessage(2, "\u00e9"), ServerMessage.parseFromBuffer(reader)) + assertEquals(4, reader.readcount) + assertInstanceOf(DisconnectMessage::class.java, ServerMessage.parseFromBuffer(reader)) + assertEquals(wire.size, reader.readcount) + } + } + + @Test + fun `unterminated high-bit strings reject atomically while legacy buffers retain their sentinel`() { + val bytes = byteArrayOf(4, 0xe9.toByte(), 0x80.toByte()) + val wire = PacketCodec.encodeSequenced(1, 0, 7, PacketBytes.copyOf(bytes)) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + val result = MessageBodyCodec.decodeClient(envelope, 1) as MessageDecodeResult.Rejected + assertEquals(MessageRejection.TRUNCATED, result.reason) + val legacy = sizebuf_t(2).apply { writeBytes(bytes.copyOfRange(1, bytes.size)) } + assertEquals("\u00e9\u0080", legacy.readString()) + } + + @Test + fun `connectionless lines stop at newline while payload strings keep it`() { + val payload = byteArrayOf(0xe9.toByte(), 10, 0xfe.toByte(), 0) + assertEquals("\u00e9\n\u00fe", MessageReader(PacketBytes.copyOf(payload), 0).readString()) + val wire = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + payload) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + assertEquals("\u00e9", (envelope as PacketEnvelope.Connectionless).commandLine) + } +} diff --git a/qcommon/src/test/resources/jake2/qcommon/network/messages/client.StringCmdMessage.highbit.bin b/qcommon/src/test/resources/jake2/qcommon/network/messages/client.StringCmdMessage.highbit.bin new file mode 100644 index 0000000000000000000000000000000000000000..c429e1d67f7c7102c79afe59bfc3ada68a985123 GIT binary patch literal 13 UcmZQ6POMZ&PE32r)$or203ZGZ4FCWD literal 0 HcmV?d00001 diff --git a/qcommon/src/test/resources/jake2/qcommon/network/messages/server.PrintMessage.highbit.bin b/qcommon/src/test/resources/jake2/qcommon/network/messages/server.PrintMessage.highbit.bin new file mode 100644 index 0000000000000000000000000000000000000000..78432a22e05b94a798c4c199014f5a911cf14972 GIT binary patch literal 10 Rcmd;LN={6B$<^?W0RRxx1E>H1 literal 0 HcmV?d00001 From 81300e4e1cc204c646d53c6c754cd47d4b97c36b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 20:58:29 +0100 Subject: [PATCH 37/57] Separate client message codecs from immutable Kotlin values --- .../jake2/server/HostClientAccountingTest.kt | 6 +- .../network/messages/NetworkMessage.java | 2 - .../messages/client/ClientMessage.java | 47 ++----------- .../client/EndOfClientPacketMessage.java | 29 -------- .../network/messages/client/MoveMessage.java | 8 +-- .../network/messages/client/NoopMessage.java | 29 -------- .../messages/client/StringCmdMessage.java | 68 ------------------- .../messages/client/UserInfoMessage.java | 52 -------------- .../messages/server/ServerMessage.java | 3 + .../messages/client/ClientMessageCodec.kt | 52 ++++++++++++++ .../client/EndOfClientPacketMessage.kt | 3 + .../network/messages/client/NoopMessage.kt | 3 + .../messages/client/StringCmdMessage.kt | 21 ++++++ .../messages/client/UserInfoMessage.kt | 3 + .../network/protocol/MessageBodyCodec.kt | 2 +- .../messages/NetworkMessageSizeTest.java | 8 +-- .../protocol/ClientMessageCodecTest.kt | 68 +++++++++++++++++++ 17 files changed, 168 insertions(+), 236 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/client/NoopMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/client/StringCmdMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/client/UserInfoMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 9d022ba6..da8253dc 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -182,11 +182,7 @@ class HostClientAccountingTest { val client = bind(0, first) val peer = bind(1, first) val invalid = object : jake2.qcommon.network.messages.client.ClientMessage( - jake2.qcommon.network.messages.client.ClientMessageType.CLC_BAD) { - override fun writeProperties(buffer: sizebuf_t) = Unit - override fun parse(buffer: sizebuf_t) = Unit - override fun getSize() = 1 - } + jake2.qcommon.network.messages.client.ClientMessageType.CLC_BAD) {} host.handleClientMessages(client, listOf(invalid, jake2.qcommon.network.messages.client.UserInfoMessage("\\name\\must_not_apply"))) assertEquals(ClientStates.CS_ZOMBIE, client.state) diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java index 606f1985..a4feba06 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java @@ -6,8 +6,6 @@ public interface NetworkMessage { void writeTo(sizebuf_t buffer); - void parse(sizebuf_t buffer); - /** * size of the message in bytes */ diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java index 9005132d..18243b25 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java @@ -2,10 +2,8 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; -import jake2.qcommon.network.protocol.MessageReadException; -import jake2.qcommon.network.protocol.MessageReader; -import jake2.qcommon.network.protocol.MessageRejection; +/** Client message identity with wire operations delegated to the separate codec. */ public abstract class ClientMessage implements NetworkMessage { public final ClientMessageType type; @@ -14,47 +12,16 @@ protected ClientMessage(ClientMessageType type) { } public final void writeTo(sizebuf_t buffer) { - buffer.writeByte(type.value); - writeProperties(buffer); + ClientMessageCodec.write(this, buffer); } - protected abstract void writeProperties(sizebuf_t buffer); + /** Size of the message in bytes. */ + public final int getSize() { + return ClientMessageCodec.size(this); + } public static ClientMessage parseFromBuffer(sizebuf_t buffer, int incomingSequence) { - if (!(buffer instanceof MessageReader)) { - MessageReader reader = MessageReader.fromBuffer(buffer); - try { return parseFromBuffer(reader, incomingSequence); } finally { buffer.readcount = reader.readcount; } - } - - if (buffer.readcount == buffer.cursize) return new EndOfClientPacketMessage(); - int opcode = buffer.readByte(); - ClientMessageType type = ClientMessageType.fromByte((byte) opcode); - if (type == null) throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unknown client opcode " + opcode); - final ClientMessage msg; - switch (type) { - case CLC_BAD: - msg = new EndOfClientPacketMessage(); - break; - case CLC_NOP: - // fixme: never sent by client directly - msg = new NoopMessage(); - break; - case CLC_USERINFO: - msg = new UserInfoMessage(); - break; - case CLC_STRINGCMD: - msg = new StringCmdMessage(); - break; - case CLC_MOVE: - msg = new MoveMessage(incomingSequence); - break; - default: - throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unsupported client opcode " + opcode); - } - if (msg != null) { - msg.parse(buffer); - } - return msg; + return ClientMessageCodec.read(buffer, incomingSequence); } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.java deleted file mode 100644 index d1eb07a9..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.java +++ /dev/null @@ -1,29 +0,0 @@ -package jake2.qcommon.network.messages.client; - -import jake2.qcommon.sizebuf_t; - -public class EndOfClientPacketMessage extends ClientMessage { - public EndOfClientPacketMessage() { - super(ClientMessageType.CLC_BAD); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "EndOfClientPacketMessage{}"; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java index 225f2d7a..6bed7449 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java @@ -33,8 +33,8 @@ public MoveMessage(boolean noCompress, int lastReceivedFrame, usercmd_t oldestCm this.newCmd = newCmd; } - @Override - protected void writeProperties(sizebuf_t buffer) { + // Codec delegation while movement state/checksum handling is migrated separately. + void writeMovementProperties(sizebuf_t buffer) { checksumIndex = buffer.cursize; // later we update with right value buffer.writeByte((byte) 0); @@ -56,7 +56,6 @@ protected void writeProperties(sizebuf_t buffer) { ); } - @Override public void parse(sizebuf_t buffer) { checksumIndex = buffer.readcount; int checksum = buffer.readByte(); @@ -198,8 +197,7 @@ public int hashCode() { return result; } - @Override - public int getSize() { + int getMovementSize() { int result = 6; int deltaFlagsOldest = getDeltaFlags(nullCmd, oldestCmd); int deltaFlagsOld = getDeltaFlags(oldestCmd, oldCmd); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/NoopMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/NoopMessage.java deleted file mode 100644 index d57f5b1b..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/NoopMessage.java +++ /dev/null @@ -1,29 +0,0 @@ -package jake2.qcommon.network.messages.client; - -import jake2.qcommon.sizebuf_t; - -public class NoopMessage extends ClientMessage { - public NoopMessage() { - super(ClientMessageType.CLC_NOP); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "NoopMessage{}"; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/StringCmdMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/StringCmdMessage.java deleted file mode 100644 index 8926d2db..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/StringCmdMessage.java +++ /dev/null @@ -1,68 +0,0 @@ -package jake2.qcommon.network.messages.client; - -import jake2.qcommon.sizebuf_t; - -/** - * Represents a user command sent from client to server, either manual or automatic. - * Some commands are built-in (the constants below). - * Other commands are redirected to the game to handle (like, 'god' or 'say') - */ -public class StringCmdMessage extends ClientMessage { - - public static final String NEW = "new"; - public static final String CONFIG_STRINGS = "configstrings"; - public static final String BASELINES = "baselines"; - public static final String BEGIN = "begin"; - public static final String NEXT_SERVER = "nextserver"; - public static final String DISCONNECT = "disconnect"; - public static final String INFO = "info"; - public static final String DOWNLOAD = "download"; - public static final String NEXT_DOWNLOAD = "nextdl"; - public static final String PRECACHE = "precache"; - - public String command; - - public StringCmdMessage() { - super(ClientMessageType.CLC_STRINGCMD); - } - - public StringCmdMessage(String command) { - this(); - this.command = command; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeString(command); - } - - @Override - public void parse(sizebuf_t buffer) { - this.command = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + command.length() + 1; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - StringCmdMessage that = (StringCmdMessage) o; - return command.equals(that.command); - } - - @Override - public int hashCode() { - return command != null ? command.hashCode() : 0; - } - - @Override - public String toString() { - return "StringCmdMessage{" + - "command='" + command + '\'' + - '}'; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/UserInfoMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/UserInfoMessage.java deleted file mode 100644 index fe90a633..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/UserInfoMessage.java +++ /dev/null @@ -1,52 +0,0 @@ -package jake2.qcommon.network.messages.client; - -import jake2.qcommon.sizebuf_t; - -public class UserInfoMessage extends ClientMessage { - - public String userInfo; - - public UserInfoMessage() { - super(ClientMessageType.CLC_USERINFO); - } - - public UserInfoMessage(String userInfo) { - this(); - this.userInfo = userInfo; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeString(userInfo); - } - - @Override - public void parse(sizebuf_t buffer) { - this.userInfo = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + userInfo.length() + 1; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - UserInfoMessage that = (UserInfoMessage) o; - return userInfo.equals(that.userInfo); - } - - @Override - public int hashCode() { - return userInfo != null ? userInfo.hashCode() : 0; - } - - @Override - public String toString() { - return "UserInfoMessage{" + - "userInfo='" + userInfo + '\'' + - '}'; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 36579061..79c4e605 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -12,6 +12,9 @@ * Common parent for all messages sent from server to client. */ public abstract class ServerMessage implements NetworkMessage { + /** Mutable decoding hook retained until server message values move to their codecs. */ + public abstract void parse(sizebuf_t buffer); + public ServerMessageType type; public ServerMessage(ServerMessageType type) { diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt new file mode 100644 index 00000000..a049d277 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt @@ -0,0 +1,52 @@ +package jake2.qcommon.network.messages.client + +import jake2.qcommon.network.protocol.MessageReadException +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.network.protocol.MessageRejection +import jake2.qcommon.sizebuf_t + +/** Client wire operations; values have no mutable parsing state. Movement retains its existing CRC/delta codec. */ +object ClientMessageCodec { + @JvmStatic + fun write(message: ClientMessage, buffer: sizebuf_t) { + buffer.writeByte(message.type.value) + when (message) { + is StringCmdMessage -> buffer.writeString(message.command) + is UserInfoMessage -> buffer.writeString(message.userInfo) + NoopMessage, EndOfClientPacketMessage -> Unit + is MoveMessage -> message.writeMovementProperties(buffer) + else -> error("Unsupported client message ${message.javaClass.name}") + } + } + + /** Size in bytes including the opcode and string terminators. */ + @JvmStatic + fun size(message: ClientMessage): Int = when (message) { + is StringCmdMessage -> 2 + message.command.length + is UserInfoMessage -> 2 + message.userInfo.length + NoopMessage, EndOfClientPacketMessage -> 1 + is MoveMessage -> message.movementSize + else -> error("Unsupported client message ${message.javaClass.name}") + } + + /** Construct complete values from bounded reads; preserve cursor updates for direct legacy factory callers. */ + @JvmStatic + fun read(buffer: sizebuf_t, incomingSequence: Int): ClientMessage { + if (buffer !is MessageReader) { + val reader = MessageReader.fromBuffer(buffer) + return try { read(reader, incomingSequence) } finally { buffer.readcount = reader.readcount } + } + if (buffer.readcount == buffer.cursize) return EndOfClientPacketMessage + val opcode = buffer.readByte() + return when (ClientMessageType.fromByte(opcode.toByte())) { + ClientMessageType.CLC_BAD -> EndOfClientPacketMessage + // Clients do not normally send no-op messages directly; retain the accepted wire opcode. + ClientMessageType.CLC_NOP -> NoopMessage + ClientMessageType.CLC_USERINFO -> UserInfoMessage(buffer.readString()) + ClientMessageType.CLC_STRINGCMD -> StringCmdMessage(buffer.readString()) + ClientMessageType.CLC_MOVE -> MoveMessage(incomingSequence).apply { parse(buffer) } + null -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, + "Unknown client opcode $opcode") + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt new file mode 100644 index 00000000..68acb6e0 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt @@ -0,0 +1,3 @@ +package jake2.qcommon.network.messages.client + +data object EndOfClientPacketMessage : ClientMessage(ClientMessageType.CLC_BAD) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt new file mode 100644 index 00000000..d4dad5aa --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt @@ -0,0 +1,3 @@ +package jake2.qcommon.network.messages.client + +data object NoopMessage : ClientMessage(ClientMessageType.CLC_NOP) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt new file mode 100644 index 00000000..e27ecada --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt @@ -0,0 +1,21 @@ +package jake2.qcommon.network.messages.client + +/** + * Represents a user command sent from client to server, either manual or automatic. + * Some commands are built-in (the constants below). + * Other commands are redirected to the game to handle (like, 'god' or 'say'). + */ +data class StringCmdMessage(val command: String) : ClientMessage(ClientMessageType.CLC_STRINGCMD) { + companion object { + const val NEW = "new" + const val CONFIG_STRINGS = "configstrings" + const val BASELINES = "baselines" + const val BEGIN = "begin" + const val NEXT_SERVER = "nextserver" + const val DISCONNECT = "disconnect" + const val INFO = "info" + const val DOWNLOAD = "download" + const val NEXT_DOWNLOAD = "nextdl" + const val PRECACHE = "precache" + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt new file mode 100644 index 00000000..e37844ae --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt @@ -0,0 +1,3 @@ +package jake2.qcommon.network.messages.client + +data class UserInfoMessage(val userInfo: String) : ClientMessage(ClientMessageType.CLC_USERINFO) 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 e1689b3d..fab43777 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt @@ -13,7 +13,7 @@ object MessageBodyCodec { decode(envelope, PacketDirection.FROM_SERVER, ServerMessage::parseFromBuffer) { it is EndOfServerPacketMessage } @JvmStatic fun decodeClient(envelope: PacketEnvelope, sequence: Int): MessageDecodeResult = - decode(envelope, PacketDirection.FROM_CLIENT, { ClientMessage.parseFromBuffer(it, sequence) }) { it is EndOfClientPacketMessage } + decode(envelope, PacketDirection.FROM_CLIENT, { ClientMessageCodec.read(it, sequence) }) { it is EndOfClientPacketMessage } private fun decode( envelope: PacketEnvelope, direction: PacketDirection, diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 23a46e31..eae33c70 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -129,8 +129,8 @@ public static Stream createTestData() { ////////////////// // CLIENT MESSAGES ////////////////// - testMessages.add(new Object[]{"client.EndOfClientPacketMessage", new EndOfClientPacketMessage()}); - testMessages.add(new Object[]{"client.NoopMessage", new NoopMessage()}); + testMessages.add(new Object[]{"client.EndOfClientPacketMessage", EndOfClientPacketMessage.INSTANCE}); + testMessages.add(new Object[]{"client.NoopMessage", NoopMessage.INSTANCE}); testMessages.add(new Object[]{"client.UserInfoMessage", new UserInfoMessage("test/user/info")}); testMessages.add(new Object[]{"client.StringCmdMessage", new StringCmdMessage("test command")}); testMessages.add(new Object[]{"client.StringCmdMessage.highbit", new StringCmdMessage("say caf\u00e9\n\u0080\u00fe")}); @@ -186,9 +186,7 @@ public void testSerializationDeserializationEquality(String testName, NetworkMes parsed = ClientMessage.parseFromBuffer(buffer, 1); if (message instanceof NopMessage - || message instanceof EndOfServerPacketMessage - || message instanceof EndOfClientPacketMessage - || message instanceof NoopMessage) { + || message instanceof EndOfServerPacketMessage) { System.err.println("Skipping test for " + message.getClass()); } else { assertEquals(message, parsed, "Message is different after serialization/deserialization"); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt new file mode 100644 index 00000000..16604ce5 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt @@ -0,0 +1,68 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.network.messages.client.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import jake2.qcommon.usercmd_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ClientMessageCodecTest { + private fun body(bytes: ByteArray, sequence: Int): MessageDecodeResult { + val wire = PacketCodec.encodeSequenced(sequence, 0, 7, PacketBytes.copyOf(bytes)) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + return MessageBodyCodec.decodeClient(envelope, sequence) + } + + @Test + fun `mixed client values encode and decode without retaining parse state`() { + val values = listOf(StringCmdMessage("new"), UserInfoMessage("\\name\\caf\u00e9"), NoopMessage) + val buffer = sizebuf_t(values.sumOf(ClientMessageCodec::size) + 1) + values.forEach { ClientMessageCodec.write(it, buffer) } + ClientMessageCodec.write(EndOfClientPacketMessage, buffer) + assertEquals(buffer.maxsize, buffer.cursize) + for (sequence in listOf(1, 17)) { + val decoded = body(buffer.data, sequence) as MessageDecodeResult.Decoded + assertEquals(values, decoded.messages) + assertSame(NoopMessage, decoded.messages.last()) + } + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(0)), 0) + assertSame(EndOfClientPacketMessage, ClientMessageCodec.read(reader, 1)) + assertSame(EndOfClientPacketMessage, ClientMessageCodec.read(reader, 17)) + } + + @Test + fun `movement delegation preserves CRC boundaries after a string and before another message`() { + val prefix = StringCmdMessage("new") + val movement = MoveMessage(false, 7, usercmd_t(), usercmd_t(), usercmd_t(), 73) + val suffix = UserInfoMessage("\\name\\player") + val values = listOf(prefix, movement, suffix) + val buffer = sizebuf_t(values.sumOf(ClientMessageCodec::size)) + values.forEach { ClientMessageCodec.write(it, buffer) } + assertEquals(buffer.maxsize, buffer.cursize) + val decoded = body(buffer.data, 73) as MessageDecodeResult.Decoded + assertEquals(values, decoded.messages) + assertTrue((decoded.messages[1] as MoveMessage).valid) + + // CRC policy belongs to the controller: a corrupted movement CRC remains structurally decodable. + val badCRC = buffer.data.clone() + val checksumOffset = prefix.size + 1 + badCRC[checksumOffset] = (badCRC[checksumOffset].toInt() xor 1).toByte() + val corrupted = body(badCRC, 73) as MessageDecodeResult.Decoded + assertFalse((corrupted.messages[1] as MoveMessage).valid) + assertEquals(suffix, corrupted.messages.last()) + } + + @Test + fun `direct codec failures copy the legacy cursor and reject truncated or unknown messages`() { + for ((bytes, reason) in listOf( + byteArrayOf(4, 'n'.code.toByte()) to MessageRejection.TRUNCATED, + byteArrayOf(99) to MessageRejection.UNKNOWN_OPCODE, + )) { + val buffer = sizebuf_t(bytes.size).apply { writeBytes(bytes) } + val error = assertThrows(MessageReadException::class.java) { ClientMessageCodec.read(buffer, 1) } + assertEquals(reason, error.reason) + assertEquals(bytes.size, buffer.readcount) + } + } +} From 93ff4dccc8b833cb23c89c843253c9ff3a24e833 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 21:22:23 +0100 Subject: [PATCH 38/57] Convert server control and text messages to Kotlin values --- .../jake2/server/GameContinuationTest.kt | 12 +- .../jake2/server/HostClientAccountingTest.kt | 6 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 2 +- .../main/java/jake2/game/GameExportsImpl.java | 6 +- .../messages/server/ConfigStringMessage.java | 104 ------------------ .../messages/server/DisconnectMessage.java | 37 ------- .../messages/server/DownloadMessage.java | 2 +- .../server/EndOfServerPacketMessage.java | 38 ------- .../messages/server/FrameHeaderMessage.java | 2 +- .../messages/server/InventoryMessage.java | 2 +- .../messages/server/LayoutMessage.java | 53 --------- .../messages/server/LegacyServerMessage.java | 25 +++++ .../messages/server/MuzzleFlash2Message.java | 2 +- .../network/messages/server/NopMessage.java | 39 ------- .../server/PacketEntitiesMessage.java | 2 +- .../messages/server/PlayerInfoMessage.java | 2 +- .../messages/server/PrintCenterMessage.java | 55 --------- .../network/messages/server/PrintMessage.java | 63 ----------- .../messages/server/ReconnectMessage.java | 39 ------- .../messages/server/ServerDataMessage.java | 2 +- .../messages/server/ServerMessage.java | 60 ++-------- .../network/messages/server/SoundMessage.java | 2 +- .../messages/server/SpawnBaselineMessage.java | 2 +- .../messages/server/StuffTextMessage.java | 55 --------- .../network/messages/server/TEMessage.java | 2 +- .../messages/server/WeaponSoundMessage.java | 2 +- .../messages/client/ClientMessageCodec.kt | 2 +- .../messages/server/ConfigStringMessage.kt | 30 +++++ .../messages/server/DisconnectMessage.kt | 4 + .../server/EndOfServerPacketMessage.kt | 3 + .../network/messages/server/LayoutMessage.kt | 3 + .../network/messages/server/NopMessage.kt | 4 + .../messages/server/PrintCenterMessage.kt | 7 ++ .../network/messages/server/PrintMessage.kt | 4 + .../messages/server/ReconnectMessage.kt | 4 + .../messages/server/ServerMessageCodec.kt | 68 ++++++++++++ .../messages/server/StuffTextMessage.kt | 12 ++ .../network/protocol/MessageBodyCodec.kt | 2 +- .../qcommon/network/protocol/MessageReader.kt | 6 + .../messages/NetworkMessageSizeTest.java | 13 +-- .../protocol/MessageStringEncodingTest.kt | 2 +- .../protocol/ServerMessageCodecTest.kt | 87 +++++++++++++++ .../java/jake2/server/GameImportsImpl.java | 2 +- .../main/kotlin/jake2/server/ServerHost.kt | 20 ++-- 44 files changed, 313 insertions(+), 576 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/DisconnectMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/LayoutMessage.java create mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/LegacyServerMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/NopMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintCenterMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/ReconnectMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/StuffTextMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ConfigStringMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerMessageCodecTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index c96ce36a..5e27aee3 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -137,9 +137,9 @@ class GameContinuationTest { assertSame(second, host.games["other"]) assertEquals("default", a.gameName) assertEquals("default", c.gameName) - assertTrue(a.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) - assertTrue(c.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) - assertFalse(b.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + assertTrue(a.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) + assertTrue(c.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) + assertFalse(b.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) finish(b, second) host.update(100) @@ -189,8 +189,8 @@ class GameContinuationTest { finish(b, second) a.state = ClientStates.CS_SPAWNED a.download = byteArrayOf(1) - a.netchan.reliablePending.add(StuffTextMessage("stale")) - a.unreliable.add(StuffTextMessage("stale")) + a.netchan.reliablePending.add(StuffTextMessage.command("stale")) + a.unreliable.add(StuffTextMessage.command("stale")) a.netchan.remote_address = null // Fail the first final notification. first.gameExports = null // Discarding a Game must not call back into its gameplay. Cvar.getInstance().find("port").forceSet(0) @@ -368,7 +368,7 @@ class GameContinuationTest { assertEquals("default", client.gameName) assertEquals("testbox2", host.games.getValue("other").mapName) assertNull(first.takeNextMap(), "Rejected continuation remains consumed") - assertFalse(client.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + assertFalse(client.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) } @ParameterizedTest diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index da8253dc..68f24fd7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -139,7 +139,7 @@ class HostClientAccountingTest { client.download = byteArrayOf(1) client.downloadsize = 1 client.downloadcount = 1 - client.unreliable.add(jake2.qcommon.network.messages.server.StuffTextMessage("stale")) + client.unreliable.add(jake2.qcommon.network.messages.server.StuffTextMessage.command("stale")) val failure = IllegalStateException("broken callback") var callbacks = 0 first.gameExports = object : GameExports by first.gameExports { @@ -166,8 +166,8 @@ class HostClientAccountingTest { val client = bind(0, first) val peer = bind(1, first) client.netchan.reliablePending.add( - jake2.qcommon.network.messages.server.StuffTextMessage("x".repeat(Defines.MAX_MSGLEN))) - peer.netchan.reliablePending.add(jake2.qcommon.network.messages.server.StuffTextMessage("peer")) + jake2.qcommon.network.messages.server.StuffTextMessage.command("x".repeat(Defines.MAX_MSGLEN))) + peer.netchan.reliablePending.add(jake2.qcommon.network.messages.server.StuffTextMessage.command("peer")) val previousSequence = peer.netchan.outgoing_sequence host.sendClientMessages() assertEquals(ClientStates.CS_ZOMBIE, client.state) 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 9e11865e..88afec24 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1120,7 +1120,7 @@ class Cake( return } - // NB: it is not sent during map change (instead - StuffTextMessage("reconnect")) + // NB: it is not sent during map change (instead - StuffTextMessage.command("reconnect")) // sent on server restart is ReconnectMessage -> { Com.Printf("ReconnectMessage: Reconnecting..\n") diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 9133a3ff..02f391da 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -1169,7 +1169,7 @@ private void spectatorRespawn(GameEntity ent) { if (!passwdOK(gameCvars.spectator_password.getTypedValue(), spectator)) { gameImports.cprintf(ent, PRINT_HIGH, "Spectator password incorrect.\n"); client.pers.spectator = false; - gameImports.unicastMessage(ent.index, new StuffTextMessage("spectator 0"), true); + gameImports.unicastMessage(ent.index, StuffTextMessage.command("spectator 0"), true); return; } @@ -1187,7 +1187,7 @@ private void spectatorRespawn(GameEntity ent) { "Server spectator limit is full."); client.pers.spectator = false; // reset his spectator var - gameImports.unicastMessage(ent.index, new StuffTextMessage("spectator 0"), true); + gameImports.unicastMessage(ent.index, StuffTextMessage.command("spectator 0"), true); return; } } else { @@ -1197,7 +1197,7 @@ private void spectatorRespawn(GameEntity ent) { if (!passwdOK(gameCvars.password.getTypedValue(), password)) { gameImports.cprintf(ent, PRINT_HIGH, "Password incorrect.\n"); client.pers.spectator = true; - gameImports.unicastMessage(ent.index, new StuffTextMessage("spectator 1"), true); + gameImports.unicastMessage(ent.index, StuffTextMessage.command("spectator 1"), true); return; } } diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java deleted file mode 100644 index 5276a73b..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java +++ /dev/null @@ -1,104 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; -import jake2.qcommon.network.protocol.MessageReadException; -import jake2.qcommon.network.protocol.MessageRejection; - -import static jake2.qcommon.Defines.*; - -public class ConfigStringMessage extends ServerMessage { - public int index; - public String config; - - public ConfigStringMessage() { - super(ServerMessageType.svc_configstring); - } - - public ConfigStringMessage(int index, String config) { - this(); - this.index = index; - this.config = config; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeShort(index); - buffer.writeString(config); - } - - @Override - public void parse(sizebuf_t buffer) { - this.index = buffer.readShort(); - if (index < 0 || index >= MAX_CONFIGSTRINGS) - throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid configstring index " + index); - this.config = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + 2 + config.length() + 1; - } - - @Override - public String toString() { - return "ConfigStringMessage{" + getType(index) + "(" + index + ")" + "=" + config + '}'; - } - - public String getType(int index) { - if (index == CS_NAME) { - return "CS_NAME"; - } else if (index == CS_CDTRACK) { - return "CS_CDTRACK"; - } else if (index == CS_SKY) { - return "CS_SKY"; - } else if (index == CS_SKYAXIS) { - return "CS_SKYAXIS"; - } else if (index == CS_SKYROTATE) { - return "CS_SKYROTATE"; - } else if (index == CS_STATUSBAR) { - return "CS_STATUSBAR"; - // fixme: what is between CS_STATUSBAR(5) and CS_AIRACCEL(29)? - } else if (index == CS_AIRACCEL) { - return "CS_AIRACCEL"; - } else if (index == CS_MAXCLIENTS) { - return "CS_MAXCLIENTS"; - } else if (index == CS_MAPCHECKSUM) { - return "CS_MAPCHECKSUM"; - } else if (index < CS_SOUNDS) { - return "CS_MODELS"; - } else if (index < CS_IMAGES) { - return "CS_SOUNDS"; - } else if (index < CS_LIGHTS) { - return "CS_IMAGES"; - } else if (index < CS_ITEMS) { - return "CS_LIGHTS"; - } else if (index < CS_PLAYERSKINS) { - return "CS_ITEMS"; - } else if (index < CS_GENERAL) { - return "CS_PLAYERSKINS"; - } else if (index < MAX_CONFIGSTRINGS) { - return "CS_GENERAL"; - } else { - // todo: validate - return "UNKNOWN"; - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - ConfigStringMessage that = (ConfigStringMessage) o; - - if (index != that.index) return false; - return config != null ? config.equals(that.config) : that.config == null; - } - - @Override - public int hashCode() { - int result = index; - result = 31 * result + (config != null ? config.hashCode() : 0); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DisconnectMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DisconnectMessage.java deleted file mode 100644 index 0683c22a..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DisconnectMessage.java +++ /dev/null @@ -1,37 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -public class DisconnectMessage extends ServerMessage { - public DisconnectMessage() { - super(ServerMessageType.svc_disconnect); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - // no other fields - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "DisconnectMessage"; - } - - @Override - public boolean equals(Object obj) { - if (obj == null) - return false; - else - return this.getClass() == obj.getClass(); - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java index c851e86d..771b8d9f 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java @@ -10,7 +10,7 @@ * * Fixme: Warning: data.length is limited by signed short (32k) */ -public class DownloadMessage extends ServerMessage { +public class DownloadMessage extends LegacyServerMessage { public byte[] data; public byte percentage; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.java deleted file mode 100644 index 7f0a87e0..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.java +++ /dev/null @@ -1,38 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -public class EndOfServerPacketMessage extends ServerMessage { - public EndOfServerPacketMessage() { - super(ServerMessageType.svc_end); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "End"; - } - - @Override - public boolean equals(Object obj) { - if (obj == null) - return false; - else - return this.getClass() == obj.getClass(); - } - -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java index f19eaafa..723f2d27 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java @@ -13,7 +13,7 @@ * Generic information about frame. * Sent along together with {@link PacketEntitiesMessage} and {@link PacketEntitiesMessage} every server frame. */ -public class FrameHeaderMessage extends ServerMessage { +public class FrameHeaderMessage extends LegacyServerMessage { public int frameNumber; public int lastFrame; public int suppressCount; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java index eb8beb5e..cf517840 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java @@ -10,7 +10,7 @@ * Holds information about how many specific items player holds. * The names of the items are sent via {@link ConfigStringMessage} starting from {@link Defines#CS_ITEMS} (Max 256). */ -public class InventoryMessage extends ServerMessage { +public class InventoryMessage extends LegacyServerMessage { public int[] inventory; public InventoryMessage() { diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/LayoutMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/LayoutMessage.java deleted file mode 100644 index 914af4f4..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/LayoutMessage.java +++ /dev/null @@ -1,53 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -public class LayoutMessage extends ServerMessage { - public String layout; - - public LayoutMessage() { - super(ServerMessageType.svc_layout); - } - - public LayoutMessage(String layout) { - this(); - this.layout = layout; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeString(layout); - } - - @Override - public void parse(sizebuf_t buffer) { - this.layout = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + layout.length() + 1; - } - - @Override - public String toString() { - return "LayoutMessage{" + - "layout='" + layout + '\'' + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - LayoutMessage that = (LayoutMessage) o; - - return layout != null ? layout.equals(that.layout) : that.layout == null; - } - - @Override - public int hashCode() { - return layout != null ? layout.hashCode() : 0; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/LegacyServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/LegacyServerMessage.java new file mode 100644 index 00000000..32ca15e3 --- /dev/null +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/LegacyServerMessage.java @@ -0,0 +1,25 @@ +package jake2.qcommon.network.messages.server; + +import jake2.qcommon.sizebuf_t; + +/** + * Transitional mutable message parser while server messages move to Kotlin values/codecs. + * @deprecated Migrate subclasses to immutable values decoded by ServerMessageCodec. + */ +@Deprecated(forRemoval = true) +public abstract class LegacyServerMessage extends ServerMessage { + protected LegacyServerMessage(ServerMessageType type) { + super(type); + } + + public abstract void parse(sizebuf_t buffer); + + @Override + public abstract int getSize(); + + protected abstract void writeProperties(sizebuf_t buffer); + + final void writeLegacyProperties(sizebuf_t buffer) { + writeProperties(buffer); + } +} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java index 1daf4a61..53c2e4e0 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java @@ -6,7 +6,7 @@ * Firing effect for monsters (very similar to WeaponSoundMessage) * [entityIndex] - index of the owner */ -public class MuzzleFlash2Message extends ServerMessage { +public class MuzzleFlash2Message extends LegacyServerMessage { public int entityIndex; public int flashType; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/NopMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/NopMessage.java deleted file mode 100644 index bf7da8f0..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/NopMessage.java +++ /dev/null @@ -1,39 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -// fixme: why do we need it? -public class NopMessage extends ServerMessage { - public NopMessage() { - super(ServerMessageType.svc_nop); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "Nop"; - } - - @Override - public boolean equals(Object obj) { - if (obj == null) - return false; - else - return this.getClass() == obj.getClass(); - } - -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java index 89d2a19f..e6dde4f0 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java @@ -10,7 +10,7 @@ /** * Contains state changes for multiple entities. */ -public class PacketEntitiesMessage extends ServerMessage { +public class PacketEntitiesMessage extends LegacyServerMessage { public final List updates = new ArrayList<>(); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java index 8104ac43..c62a39fd 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java @@ -4,7 +4,7 @@ import jake2.qcommon.player_state_t; import jake2.qcommon.sizebuf_t; -public class PlayerInfoMessage extends ServerMessage { +public class PlayerInfoMessage extends LegacyServerMessage { private player_state_t previousState; // determine what needs to be sent public int deltaFlags; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintCenterMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintCenterMessage.java deleted file mode 100644 index 1c760757..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintCenterMessage.java +++ /dev/null @@ -1,55 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -/** - * Print a message in the center of the screen, like when a door is closed. - * Sent to a particular client only - */ -public class PrintCenterMessage extends ServerMessage { - public String text; - - public PrintCenterMessage() { - super(ServerMessageType.svc_centerprint); - } - - public PrintCenterMessage(String text) { - this(); - this.text = text; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeString(text); - } - - @Override - public void parse(sizebuf_t buffer) { - this.text = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + text.length() + 1; - } - - @Override - public String toString() { - return "PrintCenterMessage{" + text + '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - PrintCenterMessage that = (PrintCenterMessage) o; - - return text != null ? text.equals(that.text) : that.text == null; - } - - @Override - public int hashCode() { - return text != null ? text.hashCode() : 0; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintMessage.java deleted file mode 100644 index 8b3622e9..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintMessage.java +++ /dev/null @@ -1,63 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -/** - * Print message to console or to the top of the screen - */ -public class PrintMessage extends ServerMessage { - - public int level; - public String text; - - public PrintMessage() { - super(ServerMessageType.svc_print); - } - - public PrintMessage(int level, String text) { - this(); - this.level = level; - this.text = text; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeByte((byte) level); - buffer.writeString(text); - - } - - @Override - public void parse(sizebuf_t buffer) { - this.level = buffer.readByte(); - this.text = buffer.readString(); - } - - @Override - public int getSize() { - return 2 + text.length() + 1; - } - - @Override - public String toString() { - return "PrintMessage{" + level + "=" + text + '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - PrintMessage that = (PrintMessage) o; - - if (level != that.level) return false; - return text != null ? text.equals(that.text) : that.text == null; - } - - @Override - public int hashCode() { - int result = level; - result = 31 * result + (text != null ? text.hashCode() : 0); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ReconnectMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ReconnectMessage.java deleted file mode 100644 index 6905a6dd..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ReconnectMessage.java +++ /dev/null @@ -1,39 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -// Important: different from "reconnect" StuffTextMessage -public class ReconnectMessage extends ServerMessage { - public ReconnectMessage() { - super(ServerMessageType.svc_reconnect); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - - } - - @Override - public void parse(sizebuf_t buffer) { - - } - - @Override - public int getSize() { - return 1; - } - - @Override - public String toString() { - return "ReconnectMessage"; - } - - @Override - public boolean equals(Object obj) { - if (obj == null) - return false; - else - return this.getClass() == obj.getClass(); - } - -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java index b3a54c38..18fff2f7 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java @@ -6,7 +6,7 @@ /** * Generic server information, sent as a response to {@link jake2.qcommon.network.messages.client.StringCmdMessage.NEW} client command */ -public class ServerDataMessage extends ServerMessage { +public class ServerDataMessage extends LegacyServerMessage { public int protocol; public int spawnCount; public boolean demo; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 79c4e605..5145e13e 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -5,17 +5,13 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; import jake2.qcommon.network.protocol.MessageReadException; -import jake2.qcommon.network.protocol.MessageReader; import jake2.qcommon.network.protocol.MessageRejection; /** * Common parent for all messages sent from server to client. */ public abstract class ServerMessage implements NetworkMessage { - /** Mutable decoding hook retained until server message values move to their codecs. */ - public abstract void parse(sizebuf_t buffer); - - public ServerMessageType type; + public final ServerMessageType type; public ServerMessage(ServerMessageType type) { this.type = type; @@ -131,71 +127,39 @@ else if ((flags & Defines.U_RENDERFX16) != 0) } public final void writeTo(sizebuf_t buffer) { - buffer.writeByte((byte) type.type); - writeProperties(buffer); + ServerMessageCodec.write(this, buffer); } - protected abstract void writeProperties(sizebuf_t buffer); + /** Size of the message in bytes, including its opcode. */ + public int getSize() { + return ServerMessageCodec.size(this); + } public static ServerMessage parseFromBuffer(sizebuf_t buffer) { - if (!(buffer instanceof MessageReader)) { - MessageReader reader = MessageReader.fromBuffer(buffer); - try { return parseFromBuffer(reader); } finally { buffer.readcount = reader.readcount; } - } - + return ServerMessageCodec.read(buffer); + } - if (buffer.readcount == buffer.cursize) return new EndOfServerPacketMessage(); - final int cmd = buffer.readByte(); - if (cmd == -1) - return new EndOfServerPacketMessage(); - ServerMessageType type = ServerMessageType.fromInt(cmd); - if (type == null) throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unknown server opcode " + cmd); - final ServerMessage msg; - // Explicit end/no-op outcomes are distinct from unsupported wire opcodes. + /** Decode the remaining mutable message families until each migrates to a Kotlin value/codec. */ + static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_t buffer) { + final LegacyServerMessage msg; switch (type) { - case svc_end: - return new EndOfServerPacketMessage(); - case svc_nop: - return null; - case svc_bad: default: - throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode " + cmd); + throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode " + type); case svc_muzzleflash: msg = new WeaponSoundMessage(); break; case svc_muzzleflash2: msg = new MuzzleFlash2Message(); break; - case svc_layout: - msg = new LayoutMessage(); - break; case svc_inventory: msg = new InventoryMessage(); break; - case svc_disconnect: - msg = new DisconnectMessage(); - break; - case svc_reconnect: - msg = new ReconnectMessage(); - break; case svc_sound: msg = new SoundMessage(); break; - case svc_print: - msg = new PrintMessage(); - break; - case svc_stufftext: - msg = new StuffTextMessage(); - break; case svc_serverdata: msg = new ServerDataMessage(); break; - case svc_configstring: - msg = new ConfigStringMessage(); - break; - case svc_centerprint: - msg = new PrintCenterMessage(); - break; case svc_frame: msg = new FrameHeaderMessage(); break; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java index 78127caf..67dc6343 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java @@ -27,7 +27,7 @@ * or the midpoint of the entity box for bmodels. * ================== */ -public class SoundMessage extends ServerMessage { +public class SoundMessage extends LegacyServerMessage { public int flags; public int soundIndex; public float volume; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java index cc8d5895..8e0aa498 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java @@ -10,7 +10,7 @@ * * Baseline is a snapshot of an entity state at the moment of level instantiation. */ -public class SpawnBaselineMessage extends ServerMessage { +public class SpawnBaselineMessage extends LegacyServerMessage { public entity_state_t entityState; private final entity_state_t base = new entity_state_t(); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/StuffTextMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/StuffTextMessage.java deleted file mode 100644 index 93c44efb..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/StuffTextMessage.java +++ /dev/null @@ -1,55 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -/** - * Sent to client stuffed into client's console buffer, \n terminated - */ -public class StuffTextMessage extends ServerMessage { - public String text; - - public StuffTextMessage() { - super(ServerMessageType.svc_stufftext); - } - - public StuffTextMessage(String text) { - this(); - this.text = text + "\n"; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeString(text); - } - - @Override - public void parse(sizebuf_t buffer) { - this.text = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + text.length() + 1; - } - - @Override - public String toString() { - return "StuffTextMessage{" + text + '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - StuffTextMessage that = (StuffTextMessage) o; - - return text != null ? text.equals(that.text) : that.text == null; - } - - @Override - public int hashCode() { - return text != null ? text.hashCode() : 0; - } -} - diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java index 951e35b4..803b4389 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java @@ -7,7 +7,7 @@ /** * Temp entity */ -public abstract class TEMessage extends ServerMessage { +public abstract class TEMessage extends LegacyServerMessage { public TEMessage(int style) { super(ServerMessageType.svc_temp_entity); validateStyle(style); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java index 6c0dd293..206be19c 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java @@ -5,7 +5,7 @@ /** * Effect for player firing (very similar to MuzzleFlash2Message) */ -public class WeaponSoundMessage extends ServerMessage { +public class WeaponSoundMessage extends LegacyServerMessage { /** * @param type - weapon index and silenced flag packed together into 1 byte diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt index a049d277..2c0fd7bd 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt @@ -37,7 +37,7 @@ object ClientMessageCodec { return try { read(reader, incomingSequence) } finally { buffer.readcount = reader.readcount } } if (buffer.readcount == buffer.cursize) return EndOfClientPacketMessage - val opcode = buffer.readByte() + val opcode = buffer.readUnsignedByte() return when (ClientMessageType.fromByte(opcode.toByte())) { ClientMessageType.CLC_BAD -> EndOfClientPacketMessage // Clients do not normally send no-op messages directly; retain the accepted wire opcode. diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ConfigStringMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ConfigStringMessage.kt new file mode 100644 index 00000000..9072c604 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ConfigStringMessage.kt @@ -0,0 +1,30 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* + +data class ConfigStringMessage(val index: Int, val config: String) : ServerMessage(ServerMessageType.svc_configstring) { + fun getType(index: Int): String = when (index) { + CS_NAME -> "CS_NAME" + CS_CDTRACK -> "CS_CDTRACK" + CS_SKY -> "CS_SKY" + CS_SKYAXIS -> "CS_SKYAXIS" + CS_SKYROTATE -> "CS_SKYROTATE" + CS_STATUSBAR -> "CS_STATUSBAR" + // Reserved configstring slots lie between CS_STATUSBAR (5) and CS_AIRACCEL (29). + CS_AIRACCEL -> "CS_AIRACCEL" + CS_MAXCLIENTS -> "CS_MAXCLIENTS" + CS_MAPCHECKSUM -> "CS_MAPCHECKSUM" + else -> when { + index < CS_SOUNDS -> "CS_MODELS" + index < CS_IMAGES -> "CS_SOUNDS" + index < CS_LIGHTS -> "CS_IMAGES" + index < CS_ITEMS -> "CS_LIGHTS" + index < CS_PLAYERSKINS -> "CS_ITEMS" + index < CS_GENERAL -> "CS_PLAYERSKINS" + index < MAX_CONFIGSTRINGS -> "CS_GENERAL" + else -> "UNKNOWN" // Incoming indices are validated by the codec. + } + } + + override fun toString(): String = "ConfigStringMessage{${getType(index)}($index)=$config}" +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt new file mode 100644 index 00000000..bd247ec3 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt @@ -0,0 +1,4 @@ +package jake2.qcommon.network.messages.server + +/** Disconnect notification; no other fields. */ +data object DisconnectMessage : ServerMessage(ServerMessageType.svc_disconnect) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt new file mode 100644 index 00000000..f18c14df --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt @@ -0,0 +1,3 @@ +package jake2.qcommon.network.messages.server + +data object EndOfServerPacketMessage : ServerMessage(ServerMessageType.svc_end) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt new file mode 100644 index 00000000..00de5a8a --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt @@ -0,0 +1,3 @@ +package jake2.qcommon.network.messages.server + +data class LayoutMessage(val layout: String) : ServerMessage(ServerMessageType.svc_layout) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt new file mode 100644 index 00000000..40e54fa8 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt @@ -0,0 +1,4 @@ +package jake2.qcommon.network.messages.server + +/** Wire no-op used when encoding; decoding skips it without publishing an application message. */ +data object NopMessage : ServerMessage(ServerMessageType.svc_nop) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt new file mode 100644 index 00000000..bfbb115a --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt @@ -0,0 +1,7 @@ +package jake2.qcommon.network.messages.server + +/** + * Print a message in the center of the screen, like when a door is closed. + * Sent to a particular client only. + */ +data class PrintCenterMessage(val text: String) : ServerMessage(ServerMessageType.svc_centerprint) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt new file mode 100644 index 00000000..c5a341b2 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt @@ -0,0 +1,4 @@ +package jake2.qcommon.network.messages.server + +/** Print message to console or to the top of the screen. */ +data class PrintMessage(val level: Int, val text: String) : ServerMessage(ServerMessageType.svc_print) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt new file mode 100644 index 00000000..7706da5a --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt @@ -0,0 +1,4 @@ +package jake2.qcommon.network.messages.server + +/** Different from the "reconnect" command sent in a [StuffTextMessage]. */ +data object ReconnectMessage : ServerMessage(ServerMessageType.svc_reconnect) 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 new file mode 100644 index 00000000..f2ba81f0 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt @@ -0,0 +1,68 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.MAX_CONFIGSTRINGS +import jake2.qcommon.network.protocol.MessageReadException +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.network.protocol.MessageRejection +import jake2.qcommon.sizebuf_t + +/** Server wire operations. Mutable array/delta families retain their existing codecs during migration. */ +object ServerMessageCodec { + @JvmStatic + fun write(message: ServerMessage, buffer: sizebuf_t) { + buffer.writeByte(message.type.type.toByte()) + when (message) { + is PrintMessage -> { buffer.writeByte(message.level.toByte()); buffer.writeString(message.text) } + is PrintCenterMessage -> buffer.writeString(message.text) + is LayoutMessage -> buffer.writeString(message.layout) + is StuffTextMessage -> buffer.writeString(message.text) + is ConfigStringMessage -> { buffer.writeShort(message.index); buffer.writeString(message.config) } + DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> Unit + is LegacyServerMessage -> message.writeLegacyProperties(buffer) + else -> error("Unsupported server message ${message.javaClass.name}") + } + } + + /** Size in bytes including the opcode, fixed-width fields, and string terminators. */ + @JvmStatic + fun size(message: ServerMessage): Int = when (message) { + is PrintMessage -> 3 + message.text.length + is PrintCenterMessage -> 2 + message.text.length + is LayoutMessage -> 2 + message.layout.length + is StuffTextMessage -> 2 + message.text.length + is ConfigStringMessage -> 4 + message.config.length + DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 + is LegacyServerMessage -> message.size + else -> error("Unsupported server message ${message.javaClass.name}") + } + + /** 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 } + } + if (buffer.readcount == buffer.cursize) return EndOfServerPacketMessage + val opcode = buffer.readUnsignedByte() + return when (val type = ServerMessageType.fromInt(opcode)) { + ServerMessageType.svc_end -> EndOfServerPacketMessage + ServerMessageType.svc_nop -> null + ServerMessageType.svc_disconnect -> DisconnectMessage + ServerMessageType.svc_reconnect -> ReconnectMessage + ServerMessageType.svc_print -> PrintMessage(buffer.readUnsignedByte(), buffer.readString()) + ServerMessageType.svc_centerprint -> PrintCenterMessage(buffer.readString()) + ServerMessageType.svc_layout -> LayoutMessage(buffer.readString()) + ServerMessageType.svc_stufftext -> StuffTextMessage(buffer.readString()) + ServerMessageType.svc_configstring -> { + val index = buffer.readUnsignedShort() + if (index >= MAX_CONFIGSTRINGS) + throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid configstring index $index") + ConfigStringMessage(index, buffer.readString()) + } + null, ServerMessageType.svc_bad -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, + buffer.readcount - 1, "Invalid server opcode $opcode") + else -> ServerMessage.readLegacyProperties(type, buffer) + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt new file mode 100644 index 00000000..058dc158 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt @@ -0,0 +1,12 @@ +package jake2.qcommon.network.messages.server + +/** + * Sent to client stuffed into client's console buffer, normally newline terminated. + * [text] contains the exact wire text. Use [command] to append the command's terminating newline. + */ +data class StuffTextMessage(val text: String) : ServerMessage(ServerMessageType.svc_stufftext) { + companion object { + /** Preserve the outbound command convention of appending one newline, including for empty commands. */ + @JvmStatic fun command(text: String): StuffTextMessage = StuffTextMessage("$text\n") + } +} 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 fab43777..b8c14b33 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt @@ -10,7 +10,7 @@ import jake2.qcommon.network.messages.server.* */ object MessageBodyCodec { @JvmStatic fun decodeServer(envelope: PacketEnvelope): MessageDecodeResult = - decode(envelope, PacketDirection.FROM_SERVER, ServerMessage::parseFromBuffer) { it is EndOfServerPacketMessage } + decode(envelope, PacketDirection.FROM_SERVER, 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 } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt index f363b46a..d366fd62 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -33,6 +33,12 @@ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { requireRemaining(this, 1) return super.readByte() } + /** Unsigned wire byte, widened once at the boundary to 0..255. */ + fun readUnsignedByte(): Int = readByte() + + /** Unsigned wire short, widened once at the boundary to 0..65535. */ + fun readUnsignedShort(): Int = readShort().toInt() and 0xffff + override fun readSignedByte(): Byte { requireRemaining(this, 1) return super.readSignedByte() diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index eae33c70..4117eb20 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -39,19 +39,19 @@ public static Stream createTestData() { ////////////////// List testMessages = new ArrayList<>(); // null/empty markers - testMessages.add(new Object[]{"server.NopMessage", new NopMessage()}); - testMessages.add(new Object[]{"server.EndOfServerPacketMessage", new EndOfServerPacketMessage()}); + testMessages.add(new Object[]{"server.NopMessage", NopMessage.INSTANCE}); + testMessages.add(new Object[]{"server.EndOfServerPacketMessage", EndOfServerPacketMessage.INSTANCE}); // ordinary messages testMessages.add(new Object[]{"server.DownloadMessage.data", new DownloadMessage(new byte[]{1, 2, 3}, (byte) 50)}); testMessages.add(new Object[]{"server.DownloadMessage.empty", new DownloadMessage()}); - testMessages.add(new Object[]{"server.DisconnectMessage", new DisconnectMessage()}); - testMessages.add(new Object[]{"server.ReconnectMessage", new ReconnectMessage()}); + testMessages.add(new Object[]{"server.DisconnectMessage", DisconnectMessage.INSTANCE}); + testMessages.add(new Object[]{"server.ReconnectMessage", ReconnectMessage.INSTANCE}); testMessages.add(new Object[]{"server.LayoutMessage", new LayoutMessage("layout")}); testMessages.add(new Object[]{"server.PrintMessage.highbit", new PrintMessage(2, "caf\u00e9\n\u0080\u00fe")}); testMessages.add(new Object[]{"server.ConfigStringMessage", new ConfigStringMessage(4, "config")}); // expect only PROTOCOL_VERSION(34) testMessages.add(new Object[]{"server.ServerDataMessage", new ServerDataMessage(PROTOCOL_VERSION, 3, false, "hello quake", 1, "q3dm6")}); - testMessages.add(new Object[]{"server.StuffTextMessage", new StuffTextMessage("stuff")}); + testMessages.add(new Object[]{"server.StuffTextMessage", StuffTextMessage.command("stuff")}); testMessages.add(new Object[]{"server.FrameHeaderMessage", new FrameHeaderMessage(1, 2, 3, 4, new byte[]{1, 1, 1, 1})}); testMessages.add(new Object[]{"server.InventoryMessage", new InventoryMessage(new int[MAX_ITEMS])}); testMessages.add(new Object[]{"server.MuzzleFlash2Message", new MuzzleFlash2Message(1, 2)}); @@ -185,8 +185,7 @@ public void testSerializationDeserializationEquality(String testName, NetworkMes else parsed = ClientMessage.parseFromBuffer(buffer, 1); - if (message instanceof NopMessage - || message instanceof EndOfServerPacketMessage) { + if (message instanceof NopMessage) { System.err.println("Skipping test for " + message.getClass()); } else { assertEquals(message, parsed, "Message is different after serialization/deserialization"); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt index 6dad8f04..a97ccce0 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageStringEncodingTest.kt @@ -23,7 +23,7 @@ class MessageStringEncodingTest { val text = (1..254).map(Int::toChar).joinToString("") val messages = listOf( PrintMessage(2, text), PrintCenterMessage(text), LayoutMessage(text), - ConfigStringMessage(4, text), StuffTextMessage(text), + ConfigStringMessage(4, text), StuffTextMessage.command(text), ServerDataMessage(PROTOCOL_VERSION, 3, false, text, 1, text), StringCmdMessage(text), UserInfoMessage(text), ) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerMessageCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerMessageCodecTest.kt new file mode 100644 index 00000000..83da9c40 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerMessageCodecTest.kt @@ -0,0 +1,87 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.MAX_CONFIGSTRINGS +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ServerMessageCodecTest { + private fun write(message: ServerMessage): ByteArray { + val buffer = sizebuf_t(ServerMessageCodec.size(message)) + ServerMessageCodec.write(message, buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + @Test + fun `mixed server values retain unsigned fields and control outcomes`() { + val values = listOf( + PrintMessage(255, "caf\u00e9"), PrintCenterMessage("center"), LayoutMessage("layout"), + ConfigStringMessage(MAX_CONFIGSTRINGS - 1, "config"), StuffTextMessage.command("reconnect"), + DisconnectMessage, ReconnectMessage, + ) + val buffer = sizebuf_t(values.sumOf(ServerMessageCodec::size) + 2) + ServerMessageCodec.write(NopMessage, buffer) + values.forEach { ServerMessageCodec.write(it, buffer) } + ServerMessageCodec.write(EndOfServerPacketMessage, buffer) + assertEquals(buffer.maxsize, buffer.cursize) + val wire = PacketCodec.encodeSequenced(1, 0, null, PacketBytes.copyOf(buffer.data)) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + val decoded = MessageBodyCodec.decodeServer(envelope) as MessageDecodeResult.Decoded + assertEquals(values, decoded.messages) + assertSame(DisconnectMessage, decoded.messages[5]) + assertSame(ReconnectMessage, decoded.messages[6]) + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(6, -1)), 0) + assertNull(ServerMessageCodec.read(reader)) + assertSame(EndOfServerPacketMessage, ServerMessageCodec.read(reader)) + assertSame(EndOfServerPacketMessage, ServerMessageCodec.read(reader)) + } + + @Test + fun `stuffed text retains exact wire newlines when decoded copied and re-encoded`() { + for (text in listOf("", "reconnect", "reconnect\n", "reconnect\n\n", "caf\u00e9\n")) { + val bytes = byteArrayOf(11) + text.toByteArray(Charsets.ISO_8859_1) + byteArrayOf(0) + val decoded = ServerMessageCodec.read(MessageReader(PacketBytes.copyOf(bytes), 0)) as StuffTextMessage + assertEquals(text, decoded.text) + assertArrayEquals(bytes, write(decoded)) + assertArrayEquals(bytes, write(decoded.copy())) + val command = StuffTextMessage.command(text) + assertEquals("$text\n", command.text) + assertArrayEquals(byteArrayOf(11) + text.toByteArray(Charsets.ISO_8859_1) + byteArrayOf(10, 0), write(command)) + } + } + + @Test + fun `unsigned reader boundaries widen high bits and retain bounded failure behavior`() { + for (value in listOf(0, 1, 127, 128, 255)) { + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(value.toByte())), 0) + assertEquals(value, reader.readUnsignedByte()) + assertEquals(MessageRejection.TRUNCATED, + assertThrows(MessageReadException::class.java) { reader.readUnsignedByte() }.reason) + } + for (value in listOf(0, 127, 128, 255, 256, 32767, 32768, 65535)) { + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(value.toByte(), (value shr 8).toByte())), 0) + assertEquals(value, reader.readUnsignedShort()) + } + val truncated = MessageReader(PacketBytes.copyOf(byteArrayOf(-1)), 0) + assertEquals(MessageRejection.TRUNCATED, + assertThrows(MessageReadException::class.java) { truncated.readUnsignedShort() }.reason) + assertEquals(0, truncated.readcount, "A truncated fixed-width field consumes no bytes") + } + + @Test + fun `direct server codec failures update cursor and reject invalid unsigned config indices`() { + for ((bytes, reason) in listOf( + byteArrayOf(10, 255.toByte(), 'x'.code.toByte()) to MessageRejection.TRUNCATED, + byteArrayOf(13, -1, -1, 0) to MessageRejection.INVALID_VALUE, + byteArrayOf(254.toByte()) to MessageRejection.UNKNOWN_OPCODE, + )) { + val buffer = sizebuf_t(bytes.size).apply { writeBytes(bytes) } + val error = assertThrows(MessageReadException::class.java) { ServerMessageCodec.read(buffer) } + assertEquals(reason, error.reason) + assertEquals(if (reason == MessageRejection.INVALID_VALUE) 3 else bytes.size, buffer.readcount) + } + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index bdeb4de3..f2618162 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -899,7 +899,7 @@ void SV_BroadcastCommand(String s) { for (ClientNetworkInfo client : serverMain.getClients()) { if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) continue; - client.netchan.reliablePending.add(new StuffTextMessage(s)); + client.netchan.reliablePending.add(StuffTextMessage.command(s)); } } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 9ff26317..3bdbd120 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -684,7 +684,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) if (client.state == ClientStates.CS_SPAWNED) client.state = ClientStates.CS_CONNECTED client.lastReceivedFrame = -1 if (client.state == ClientStates.CS_CONNECTED) { - client.netchan.reliablePending.add(StuffTextMessage("changing")) + client.netchan.reliablePending.add(StuffTextMessage.command("changing")) } } @@ -705,7 +705,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) for (client in transitionClients) { if (client.state == ClientStates.CS_CONNECTED) { - client.netchan.reliablePending.add(StuffTextMessage("reconnect")) + client.netchan.reliablePending.add(StuffTextMessage.command("reconnect")) } } return gameImports @@ -1127,7 +1127,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) fun dropClient(client: ClientNetworkInfo) { if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) return try { - client.netchan.reliablePending.add(DisconnectMessage()) + client.netchan.reliablePending.add(DisconnectMessage) if (client.state == ClientStates.CS_SPAWNED) { games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) } @@ -1369,7 +1369,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // begin fetching configstrings client.netchan.reliablePending.add( - StuffTextMessage( + StuffTextMessage.command( String.format( "cmd %s %d 0", StringCmdMessage.CONFIG_STRINGS, @@ -1420,7 +1420,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } else { String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) } - client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + client.netchan.reliablePending.add(StuffTextMessage.command(nextCmd)) } private fun sendBaselines(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1463,7 +1463,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // continue from where we finished String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) } - client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + client.netchan.reliablePending.add(StuffTextMessage.command(nextCmd)) } private fun beginClient(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1686,7 +1686,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) for (cl in getClientsForInstance(gameName)) { if (cl == null || level < cl.messagelevel) continue if (cl.state != ClientStates.CS_SPAWNED) continue - cl.netchan.reliablePending.add(PrintMessage(level, message)) + cl.netchan.reliablePending.add(PrintMessage(level, message.orEmpty())) } } @@ -1729,10 +1729,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) recipients: List = clients ) { val msgs: MutableCollection = ArrayList() - msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) + msgs.add(PrintMessage(Defines.PRINT_HIGH, message.orEmpty())) - if (reconnect) msgs.add(ReconnectMessage()) - else msgs.add(DisconnectMessage()) + if (reconnect) msgs.add(ReconnectMessage) + else msgs.add(DisconnectMessage) // send it twice // stagger the packets to crutch operating system limited buffers From 5969b918aad6130577aec9449735b663a8709fee Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 21:26:37 +0100 Subject: [PATCH 39/57] Group compact Kotlin message values by protocol role --- .../{StringCmdMessage.kt => ClientMessages.kt} | 6 ++++++ .../messages/client/EndOfClientPacketMessage.kt | 3 --- .../qcommon/network/messages/client/NoopMessage.kt | 3 --- .../network/messages/client/UserInfoMessage.kt | 3 --- .../network/messages/server/DisconnectMessage.kt | 4 ---- .../messages/server/EndOfServerPacketMessage.kt | 3 --- .../qcommon/network/messages/server/LayoutMessage.kt | 3 --- .../qcommon/network/messages/server/NopMessage.kt | 4 ---- .../network/messages/server/PrintCenterMessage.kt | 7 ------- .../qcommon/network/messages/server/PrintMessage.kt | 4 ---- .../network/messages/server/ReconnectMessage.kt | 4 ---- .../network/messages/server/ServerControlMessages.kt | 12 ++++++++++++ .../{StuffTextMessage.kt => ServerTextMessages.kt} | 11 +++++++++++ 13 files changed, 29 insertions(+), 38 deletions(-) rename qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/{StringCmdMessage.kt => ClientMessages.kt} (76%) delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt delete mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerControlMessages.kt rename qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/{StuffTextMessage.kt => ServerTextMessages.kt} (54%) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessages.kt similarity index 76% rename from qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt rename to qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessages.kt index e27ecada..04c80552 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/StringCmdMessage.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessages.kt @@ -1,5 +1,11 @@ package jake2.qcommon.network.messages.client +data object EndOfClientPacketMessage : ClientMessage(ClientMessageType.CLC_BAD) + +data object NoopMessage : ClientMessage(ClientMessageType.CLC_NOP) + +data class UserInfoMessage(val userInfo: String) : ClientMessage(ClientMessageType.CLC_USERINFO) + /** * Represents a user command sent from client to server, either manual or automatic. * Some commands are built-in (the constants below). diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt deleted file mode 100644 index 68acb6e0..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.kt +++ /dev/null @@ -1,3 +0,0 @@ -package jake2.qcommon.network.messages.client - -data object EndOfClientPacketMessage : ClientMessage(ClientMessageType.CLC_BAD) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt deleted file mode 100644 index d4dad5aa..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/NoopMessage.kt +++ /dev/null @@ -1,3 +0,0 @@ -package jake2.qcommon.network.messages.client - -data object NoopMessage : ClientMessage(ClientMessageType.CLC_NOP) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt deleted file mode 100644 index e37844ae..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/UserInfoMessage.kt +++ /dev/null @@ -1,3 +0,0 @@ -package jake2.qcommon.network.messages.client - -data class UserInfoMessage(val userInfo: String) : ClientMessage(ClientMessageType.CLC_USERINFO) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt deleted file mode 100644 index bd247ec3..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DisconnectMessage.kt +++ /dev/null @@ -1,4 +0,0 @@ -package jake2.qcommon.network.messages.server - -/** Disconnect notification; no other fields. */ -data object DisconnectMessage : ServerMessage(ServerMessageType.svc_disconnect) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt deleted file mode 100644 index f18c14df..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EndOfServerPacketMessage.kt +++ /dev/null @@ -1,3 +0,0 @@ -package jake2.qcommon.network.messages.server - -data object EndOfServerPacketMessage : ServerMessage(ServerMessageType.svc_end) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt deleted file mode 100644 index 00de5a8a..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/LayoutMessage.kt +++ /dev/null @@ -1,3 +0,0 @@ -package jake2.qcommon.network.messages.server - -data class LayoutMessage(val layout: String) : ServerMessage(ServerMessageType.svc_layout) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt deleted file mode 100644 index 40e54fa8..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/NopMessage.kt +++ /dev/null @@ -1,4 +0,0 @@ -package jake2.qcommon.network.messages.server - -/** Wire no-op used when encoding; decoding skips it without publishing an application message. */ -data object NopMessage : ServerMessage(ServerMessageType.svc_nop) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt deleted file mode 100644 index bfbb115a..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintCenterMessage.kt +++ /dev/null @@ -1,7 +0,0 @@ -package jake2.qcommon.network.messages.server - -/** - * Print a message in the center of the screen, like when a door is closed. - * Sent to a particular client only. - */ -data class PrintCenterMessage(val text: String) : ServerMessage(ServerMessageType.svc_centerprint) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt deleted file mode 100644 index c5a341b2..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PrintMessage.kt +++ /dev/null @@ -1,4 +0,0 @@ -package jake2.qcommon.network.messages.server - -/** Print message to console or to the top of the screen. */ -data class PrintMessage(val level: Int, val text: String) : ServerMessage(ServerMessageType.svc_print) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt deleted file mode 100644 index 7706da5a..00000000 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ReconnectMessage.kt +++ /dev/null @@ -1,4 +0,0 @@ -package jake2.qcommon.network.messages.server - -/** Different from the "reconnect" command sent in a [StuffTextMessage]. */ -data object ReconnectMessage : ServerMessage(ServerMessageType.svc_reconnect) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerControlMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerControlMessages.kt new file mode 100644 index 00000000..9f5d8fcd --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerControlMessages.kt @@ -0,0 +1,12 @@ +package jake2.qcommon.network.messages.server + +/** Disconnect notification; no other fields. */ +data object DisconnectMessage : ServerMessage(ServerMessageType.svc_disconnect) + +/** Different from the "reconnect" command sent in a [StuffTextMessage]. */ +data object ReconnectMessage : ServerMessage(ServerMessageType.svc_reconnect) + +/** Wire no-op used when encoding; decoding skips it without publishing an application message. */ +data object NopMessage : ServerMessage(ServerMessageType.svc_nop) + +data object EndOfServerPacketMessage : ServerMessage(ServerMessageType.svc_end) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerTextMessages.kt similarity index 54% rename from qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt rename to qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerTextMessages.kt index 058dc158..e939d122 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/StuffTextMessage.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerTextMessages.kt @@ -1,5 +1,16 @@ package jake2.qcommon.network.messages.server +/** Print message to console or to the top of the screen. */ +data class PrintMessage(val level: Int, val text: String) : ServerMessage(ServerMessageType.svc_print) + +/** + * Print a message in the center of the screen, like when a door is closed. + * Sent to a particular client only. + */ +data class PrintCenterMessage(val text: String) : ServerMessage(ServerMessageType.svc_centerprint) + +data class LayoutMessage(val layout: String) : ServerMessage(ServerMessageType.svc_layout) + /** * Sent to client stuffed into client's console buffer, normally newline terminated. * [text] contains the exact wire text. Use [command] to append the command's terminating newline. From e94fbde952a519b8868235ff1c4c570a321a79fb Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 21:32:36 +0100 Subject: [PATCH 40/57] Convert server metadata and firing effects to Kotlin values --- .../java/jake2/server/GameImportsTest.java | 4 +- .../ingame/IngameSoundMessageHandler.kt | 8 +- .../messages/server/MuzzleFlash2Message.java | 66 ------------- .../messages/server/ServerDataMessage.java | 99 ------------------- .../messages/server/ServerMessage.java | 9 -- .../messages/server/WeaponSoundMessage.java | 70 ------------- .../messages/server/ServerDataMessage.kt | 25 +++++ .../messages/server/ServerEffectMessages.kt | 14 +++ .../messages/server/ServerMessageCodec.kt | 26 +++++ .../qcommon/network/protocol/MessageReader.kt | 3 + .../messages/NetworkMessageSizeTest.java | 2 +- .../protocol/ServerScalarMessageCodecTest.kt | 63 ++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 3 +- 13 files changed, 139 insertions(+), 253 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerDataMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerEffectMessages.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerScalarMessageCodecTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index b93708d5..5690e7cc 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -369,9 +369,9 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { manager.executeClientCommand(firstClient, "new"); manager.executeClientCommand(secondClient, "new"); Assertions.assertEquals("first game", - ((ServerDataMessage) firstClient.netchan.reliablePending.iterator().next()).levelString); + ((ServerDataMessage) firstClient.netchan.reliablePending.iterator().next()).getLevelString()); Assertions.assertEquals("second game", - ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).levelString); + ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).getLevelString()); firstClient.netchan.reliablePending.clear(); manager.executeClientCommand(firstClient, "configstrings " + first.spawncount + " 0"); Assertions.assertTrue(firstClient.netchan.reliablePending.contains( 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 dd2a4807..f012f069 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 @@ -116,10 +116,10 @@ internal class IngameSoundMessageHandler( Com.Warn("Ignoring WeaponSoundMessage with invalid entity index ${msg.entityIndex}") return } - // weapon type is stored in last 7 bits of the msg.type - val weaponType = msg.type and 0x7F - // the silenced flag is stored in the first bit - val silenced = (msg.type and Defines.MZ_SILENCED) != 0 + // The weapon type occupies the low seven bits of the packed flash type. + val weaponType = msg.flashType and 0x7F + // The silenced flag occupies the high bit. + val silenced = (msg.flashType and Defines.MZ_SILENCED) != 0 val volume = if (silenced) 0.2f else 1f spawnWeaponMuzzleFlashLight(msg.entityIndex, weaponType, silenced) diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java deleted file mode 100644 index 53c2e4e0..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java +++ /dev/null @@ -1,66 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -/** - * Firing effect for monsters (very similar to WeaponSoundMessage) - * [entityIndex] - index of the owner - */ -public class MuzzleFlash2Message extends LegacyServerMessage { - public int entityIndex; - public int flashType; - - public MuzzleFlash2Message() { - super(ServerMessageType.svc_muzzleflash2); - } - - public MuzzleFlash2Message(int entityIndex, int flashType) { - this(); - this.entityIndex = entityIndex; - this.flashType = flashType; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeShort(entityIndex); - buffer.writeByte((byte) flashType); - } - - @Override - public void parse(sizebuf_t buffer) { - this.entityIndex = buffer.readShort(); - validateEntityIndex(buffer, entityIndex); - this.flashType = buffer.readByte(); - } - - @Override - public int getSize() { - return 4; - } - - @Override - public String toString() { - return "MuzzleFlash2Message{" + - "entityIndex=" + entityIndex + - ", flashType=" + flashType + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - MuzzleFlash2Message that = (MuzzleFlash2Message) o; - - if (entityIndex != that.entityIndex) return false; - return flashType == that.flashType; - } - - @Override - public int hashCode() { - int result = entityIndex; - result = 31 * result + flashType; - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java deleted file mode 100644 index 18fff2f7..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerDataMessage.java +++ /dev/null @@ -1,99 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.Defines; -import jake2.qcommon.sizebuf_t; - -/** - * Generic server information, sent as a response to {@link jake2.qcommon.network.messages.client.StringCmdMessage.NEW} client command - */ -public class ServerDataMessage extends LegacyServerMessage { - public int protocol; - public int spawnCount; - public boolean demo; - public String gameName; - public int playerNumber; - public String levelString; - - public ServerDataMessage() { - super(ServerMessageType.svc_serverdata); - } - - /** - * @param gameName - name of the mod folder, empty means "baseq2" - */ - public ServerDataMessage(int protocol, int spawnCount, boolean demo, String gameName, int playerNumber, String levelString) { - this(); - this.protocol = protocol; - this.spawnCount = spawnCount; - this.demo = demo; - this.gameName = gameName; - this.playerNumber = playerNumber; - this.levelString = levelString; - } - - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeInt(Defines.PROTOCOL_VERSION); - buffer.writeInt(spawnCount); - float c = demo ? 1 : 0; - buffer.writeByte((byte) c); - buffer.writeString(gameName); - buffer.writeShort(playerNumber); - buffer.writeString(levelString); - - } - - @Override - public void parse(sizebuf_t buffer) { - this.protocol = buffer.readInt(); - this.spawnCount = buffer.readInt(); - this.demo = buffer.readByte() != 0; - this.gameName = buffer.readString(); - this.playerNumber = buffer.readShort(); - this.levelString = buffer.readString(); - } - - @Override - public int getSize() { - return 1 + 4 + 4 + 1 + gameName.length() + 1 + 2 + levelString.length() + 1; - } - - @Override - public String toString() { - return "ServerDataMessage{" + - "protocol=" + protocol + - ", spawnCount=" + spawnCount + - ", demo=" + demo + - ", gameName='" + gameName + '\'' + - ", playerNumber=" + playerNumber + - ", levelString='" + levelString + '\'' + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - ServerDataMessage that = (ServerDataMessage) o; - - if (protocol != that.protocol) return false; - if (spawnCount != that.spawnCount) return false; - if (demo != that.demo) return false; - if (playerNumber != that.playerNumber) return false; - if (gameName != null ? !gameName.equals(that.gameName) : that.gameName != null) return false; - return levelString != null ? levelString.equals(that.levelString) : that.levelString == null; - } - - @Override - public int hashCode() { - int result = protocol; - result = 31 * result + spawnCount; - result = 31 * result + (demo ? 1 : 0); - result = 31 * result + (gameName != null ? gameName.hashCode() : 0); - result = 31 * result + playerNumber; - result = 31 * result + (levelString != null ? levelString.hashCode() : 0); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 5145e13e..0ad00087 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -145,21 +145,12 @@ static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_ switch (type) { default: throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode " + type); - case svc_muzzleflash: - msg = new WeaponSoundMessage(); - break; - case svc_muzzleflash2: - msg = new MuzzleFlash2Message(); - break; case svc_inventory: msg = new InventoryMessage(); break; case svc_sound: msg = new SoundMessage(); break; - case svc_serverdata: - msg = new ServerDataMessage(); - break; case svc_frame: msg = new FrameHeaderMessage(); break; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java deleted file mode 100644 index 206be19c..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java +++ /dev/null @@ -1,70 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -/** - * Effect for player firing (very similar to MuzzleFlash2Message) - */ -public class WeaponSoundMessage extends LegacyServerMessage { - - /** - * @param type - weapon index and silenced flag packed together into 1 byte - * @param entityIndex - index of the owner (player) - */ - public WeaponSoundMessage(int entityIndex, int type) { - this(); - this.entityIndex = entityIndex; - this.type = type; - } - - public WeaponSoundMessage() { - super(ServerMessageType.svc_muzzleflash); - } - - public int entityIndex; - public int type; - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeShort(entityIndex); - buffer.writeByte((byte) type); - } - - @Override - public void parse(sizebuf_t buffer) { - this.entityIndex = buffer.readShort(); - validateEntityIndex(buffer, entityIndex); - this.type = buffer.readByte(); - } - - @Override - public int getSize() { - return 4; - } - - @Override - public String toString() { - return "WeaponSoundMessage{" + - "entityIndex=" + entityIndex + - ", type=" + type + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - WeaponSoundMessage that = (WeaponSoundMessage) o; - - if (entityIndex != that.entityIndex) return false; - return type == that.type; - } - - @Override - public int hashCode() { - int result = entityIndex; - result = 31 * result + type; - return result; - } -} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerDataMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerDataMessage.kt new file mode 100644 index 00000000..466d7866 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerDataMessage.kt @@ -0,0 +1,25 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.PROTOCOL_VERSION +import jake2.qcommon.network.messages.client.StringCmdMessage + +/** + * Generic server information, sent as a response to the [StringCmdMessage.NEW] client command. + * @property gameName name of the mod folder; empty means "baseq2". + * @property playerNumber local player slot, or -1 for cinematic/picture presentation. + */ +data class ServerDataMessage( + val protocol: Int, + val spawnCount: Int, + val demo: Boolean, + val gameName: String, + val playerNumber: Int, + val levelString: String, +) : ServerMessage(ServerMessageType.svc_serverdata) { + companion object { + /** Outbound server data uses the supported protocol; decoded values retain their received version. */ + @JvmStatic + fun forClient(spawnCount: Int, demo: Boolean, gameName: String, playerNumber: Int, levelString: String): ServerDataMessage = + ServerDataMessage(PROTOCOL_VERSION, spawnCount, demo, gameName, playerNumber, levelString) + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerEffectMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerEffectMessages.kt new file mode 100644 index 00000000..7d1ecd65 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerEffectMessages.kt @@ -0,0 +1,14 @@ +package jake2.qcommon.network.messages.server + +/** + * Effect for player firing (very similar to [MuzzleFlash2Message]). + * @property entityIndex index of the owner (player). + * @property flashType weapon index and silenced flag packed together into one byte. + */ +data class WeaponSoundMessage(val entityIndex: Int, val flashType: Int) : ServerMessage(ServerMessageType.svc_muzzleflash) + +/** + * Firing effect for monsters (very similar to [WeaponSoundMessage]). + * @property entityIndex index of the owner. + */ +data class MuzzleFlash2Message(val entityIndex: Int, val flashType: Int) : ServerMessage(ServerMessageType.svc_muzzleflash2) 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 f2ba81f0..d65457d4 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 @@ -1,6 +1,7 @@ package jake2.qcommon.network.messages.server import jake2.qcommon.Defines.MAX_CONFIGSTRINGS +import jake2.qcommon.Defines.MAX_EDICTS import jake2.qcommon.network.protocol.MessageReadException import jake2.qcommon.network.protocol.MessageReader import jake2.qcommon.network.protocol.MessageRejection @@ -17,6 +18,16 @@ object ServerMessageCodec { is LayoutMessage -> buffer.writeString(message.layout) is StuffTextMessage -> buffer.writeString(message.text) is ConfigStringMessage -> { buffer.writeShort(message.index); buffer.writeString(message.config) } + is ServerDataMessage -> { + buffer.writeInt(message.protocol) + buffer.writeInt(message.spawnCount) + buffer.writeByte(if (message.demo) 1 else 0) + buffer.writeString(message.gameName) + buffer.writeShort(message.playerNumber) + buffer.writeString(message.levelString) + } + is WeaponSoundMessage -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } + is MuzzleFlash2Message -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> Unit is LegacyServerMessage -> message.writeLegacyProperties(buffer) else -> error("Unsupported server message ${message.javaClass.name}") @@ -31,6 +42,8 @@ object ServerMessageCodec { is LayoutMessage -> 2 + message.layout.length is StuffTextMessage -> 2 + message.text.length is ConfigStringMessage -> 4 + message.config.length + is ServerDataMessage -> 14 + message.gameName.length + message.levelString.length + is WeaponSoundMessage, is MuzzleFlash2Message -> 4 DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 is LegacyServerMessage -> message.size else -> error("Unsupported server message ${message.javaClass.name}") @@ -54,6 +67,12 @@ object ServerMessageCodec { ServerMessageType.svc_centerprint -> PrintCenterMessage(buffer.readString()) ServerMessageType.svc_layout -> LayoutMessage(buffer.readString()) ServerMessageType.svc_stufftext -> StuffTextMessage(buffer.readString()) + ServerMessageType.svc_serverdata -> ServerDataMessage( + buffer.readInt(), buffer.readInt(), buffer.readUnsignedByte() != 0, + buffer.readString(), buffer.readSignedShort(), buffer.readString(), + ) + ServerMessageType.svc_muzzleflash -> WeaponSoundMessage(readEntityIndex(buffer), buffer.readUnsignedByte()) + ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_configstring -> { val index = buffer.readUnsignedShort() if (index >= MAX_CONFIGSTRINGS) @@ -65,4 +84,11 @@ object ServerMessageCodec { else -> ServerMessage.readLegacyProperties(type, buffer) } } + + private fun readEntityIndex(buffer: MessageReader): Int { + val index = buffer.readUnsignedShort() + if (index >= MAX_EDICTS) + throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number $index") + return index + } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt index d366fd62..39793138 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -39,6 +39,9 @@ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { /** Unsigned wire short, widened once at the boundary to 0..65535. */ fun readUnsignedShort(): Int = readShort().toInt() and 0xffff + /** Signed wire short, widened once at the boundary to -32768..32767, including negative protocol markers. */ + fun readSignedShort(): Int = readShort().toInt() + override fun readSignedByte(): Byte { requireRemaining(this, 1) return super.readSignedByte() diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 4117eb20..5b1c32e8 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -50,7 +50,7 @@ public static Stream createTestData() { testMessages.add(new Object[]{"server.PrintMessage.highbit", new PrintMessage(2, "caf\u00e9\n\u0080\u00fe")}); testMessages.add(new Object[]{"server.ConfigStringMessage", new ConfigStringMessage(4, "config")}); // expect only PROTOCOL_VERSION(34) - testMessages.add(new Object[]{"server.ServerDataMessage", new ServerDataMessage(PROTOCOL_VERSION, 3, false, "hello quake", 1, "q3dm6")}); + testMessages.add(new Object[]{"server.ServerDataMessage", ServerDataMessage.forClient(3, false, "hello quake", 1, "q3dm6")}); testMessages.add(new Object[]{"server.StuffTextMessage", StuffTextMessage.command("stuff")}); testMessages.add(new Object[]{"server.FrameHeaderMessage", new FrameHeaderMessage(1, 2, 3, 4, new byte[]{1, 1, 1, 1})}); testMessages.add(new Object[]{"server.InventoryMessage", new InventoryMessage(new int[MAX_ITEMS])}); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerScalarMessageCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerScalarMessageCodecTest.kt new file mode 100644 index 00000000..c8c19517 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ServerScalarMessageCodecTest.kt @@ -0,0 +1,63 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.MAX_EDICTS +import jake2.qcommon.Defines.PROTOCOL_VERSION +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ServerScalarMessageCodecTest { + private fun write(message: ServerMessage): ByteArray { + val buffer = sizebuf_t(ServerMessageCodec.size(message)) + ServerMessageCodec.write(message, buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray): ServerMessage { + val reader = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ServerMessageCodec.read(reader)!! + assertEquals(bytes.size, reader.readcount) + return message + } + + @Test + fun `received server metadata retains its version high-bit text and cinematic slot`() { + // An unsupported version is metadata for the controller to interpret, not a codec rewrite rule. + val bytes = byteArrayOf(12, 33, 0, 0, 0, 0x78, 0x56, 0x34, 0x12, 1, + 'b'.code.toByte(), 0xe9.toByte(), 0, -1, -1, 'l'.code.toByte(), 0xfe.toByte(), 0) + val message = read(bytes) as ServerDataMessage + assertEquals(ServerDataMessage(33, 0x12345678, true, "b\u00e9", -1, "l\u00fe"), message) + assertArrayEquals(bytes, write(message.copy())) + for (slot in listOf(-1, 0, 255)) for (demo in listOf(false, true)) { + val outgoing = ServerDataMessage.forClient(3, demo, "", slot, "level") + assertEquals(PROTOCOL_VERSION, outgoing.protocol) + assertEquals(outgoing, read(write(outgoing))) + } + } + + @Test + fun `all packed effect bytes retain their opcode and entity number width`() { + for (index in listOf(0, 1, 255, 256, MAX_EDICTS - 1)) for (flash in 0..255) { + for ((opcode, message) in listOf(1 to WeaponSoundMessage(index, flash), 2 to MuzzleFlash2Message(index, flash))) { + val bytes = byteArrayOf(opcode.toByte(), index.toByte(), (index shr 8).toByte(), flash.toByte()) + assertArrayEquals(bytes, write(message)) + assertEquals(message, read(bytes)) + } + } + } + + @Test + fun `signed short reads preserve negative markers and bounded failures`() { + for (value in listOf(-32768, -1, 0, 32767)) { + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(value.toByte(), (value shr 8).toByte())), 0) + assertEquals(value, reader.readSignedShort()) + } + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(-1)), 0) + val error = assertThrows(MessageReadException::class.java) { reader.readSignedShort() } + assertEquals(MessageRejection.TRUNCATED, error.reason) + assertEquals(0, reader.readcount) + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 3bdbd120..fd6e2cf3 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1348,8 +1348,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } client.netchan.reliablePending.add( - ServerDataMessage( - Defines.PROTOCOL_VERSION, + ServerDataMessage.forClient( gameImports.spawncount, gameImports.isDemo, gamedir, From ece208140d70b2d9b006523cce67b3e515882c89 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 21:52:31 +0100 Subject: [PATCH 41/57] Own inventory download and frame message payloads --- .../java/jake2/server/GameImportsTest.java | 8 +- .../jake2/server/EmbeddedCakeHostTest.kt | 2 +- .../jake2/server/HostClientAccountingTest.kt | 21 ++++ .../src/main/kotlin/org/demoth/cake/Cake.kt | 4 +- .../cake/download/CakeDownloadTransfer.kt | 13 +- .../cake/stages/ingame/ClientEntityManager.kt | 2 +- .../demoth/cake/stages/ingame/Game3dScreen.kt | 4 +- .../cake/download/CakeDownloadSessionTest.kt | 26 ++-- .../cake/download/CakeDownloadTransferTest.kt | 19 ++- .../ClientEntityManagerFrameSelectionTest.kt | 17 +++ .../messages/server/DownloadMessage.java | 84 ------------- .../messages/server/FrameHeaderMessage.java | 103 --------------- .../messages/server/InventoryMessage.java | 67 ---------- .../messages/server/ServerMessage.java | 9 -- .../messages/server/DownloadMessage.kt | 26 ++++ .../messages/server/FrameHeaderMessage.kt | 22 ++++ .../messages/server/InventoryMessage.kt | 21 ++++ .../messages/server/ServerMessageCodec.kt | 47 +++++++ .../qcommon/network/protocol/MessageReader.kt | 6 + .../qcommon/network/transport/PacketBytes.kt | 9 ++ .../messages/NetworkMessageSizeTest.java | 4 +- .../network/protocol/MessageBodyCodecTest.kt | 2 +- .../protocol/OwnedMessagePayloadTest.kt | 117 ++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 10 +- 24 files changed, 339 insertions(+), 304 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DownloadMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/FrameHeaderMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/InventoryMessage.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/OwnedMessagePayloadTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 5690e7cc..c2405b29 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -177,7 +177,7 @@ public void clientSnapshotsKeepIndependentGameHistory() { var initial = List.copyOf(first.buildClientFrameMessages(firstClient)); Assertions.assertEquals(3, initial.size()); - Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).lastFrame); + Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).getLastFrame()); EntityUpdate initialUpdate = ((PacketEntitiesMessage) initial.get(2)).updates.get(0); Assertions.assertTrue(initialUpdate.force); Assertions.assertEquals(11, initialUpdate.newState.modelindex); @@ -193,8 +193,8 @@ public void clientSnapshotsKeepIndependentGameHistory() { int oldModel = game == first ? 11 : 22; var messages = List.copyOf(game.buildClientFrameMessages(client)); FrameHeaderMessage header = (FrameHeaderMessage) messages.get(0); - Assertions.assertEquals(2, header.frameNumber); - Assertions.assertEquals(1, header.lastFrame); + Assertions.assertEquals(2, header.getFrameNumber()); + Assertions.assertEquals(1, header.getLastFrame()); var updates = ((PacketEntitiesMessage) messages.get(2)).updates; Assertions.assertEquals(1, updates.size()); Assertions.assertEquals(oldModel, updates.get(0).oldState.modelindex); @@ -213,7 +213,7 @@ public void clientSnapshotsKeepIndependentGameHistory() { // An acknowledgement outside the retained frame window falls back to a baseline update. second.frameNum += Defines.UPDATE_BACKUP; var stale = List.copyOf(second.buildClientFrameMessages(secondClient)); - Assertions.assertEquals(-1, ((FrameHeaderMessage) stale.get(0)).lastFrame); + Assertions.assertEquals(-1, ((FrameHeaderMessage) stale.get(0)).getLastFrame()); Assertions.assertTrue(((PacketEntitiesMessage) stale.get(2)).updates.get(0).force); } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index bac01c58..6f30bccc 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -170,7 +170,7 @@ class EmbeddedCakeHostTest { val buffer = sizebuf_t(64) client.netchan.reliablePending.single().writeTo(buffer) val reply = ServerMessage.parseFromBuffer(buffer) as DownloadMessage - assertNull(reply.data) + assertTrue(reply is DownloadMessage.Failure) assertEquals(CakeDownloadSession.Result.MissingOnServer, downloads.handle(reply)) assertFalse(Files.exists(cache.resolve("baseq2/$path"))) if (resume) assertArrayEquals(byteArrayOf(1, 2, 3), Files.readAllBytes(partial)) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 68f24fd7..237260aa 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -7,6 +7,7 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import jake2.qcommon.network.* import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.network.messages.server.DownloadMessage import jake2.qcommon.vfs.* import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* @@ -177,6 +178,26 @@ class HostClientAccountingTest { assertSame(second, host.games[second.name]) } + @Test + fun `large downloads keep unsigned progress accurate through their final queued chunks`() { + val client = bind(0, first) + val source = ByteArray(24 * 1024 * 1024) + client.download = source + client.downloadsize = source.size + client.downloadcount = source.size - 2048 + host.executeClientCommand(client, "nextdl") + val firstChunk = client.netchan.reliablePending.single() as DownloadMessage.Chunk + assertEquals(99, firstChunk.percentage) + assertEquals(1024, firstChunk.data.size) + source[source.size - 2048] = 77 + assertEquals(0.toByte(), firstChunk.data.toByteArray()[0]) + host.executeClientCommand(client, "nextdl") + val lastChunk = client.netchan.reliablePending.last() as DownloadMessage.Chunk + assertEquals(100, lastChunk.percentage) + assertEquals(1024, lastChunk.data.size) + assertNull(client.download) + } + @Test fun `rejected client packet cannot execute trailing commands`() { val client = bind(0, first) 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 88afec24..3ffcc1c7 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -1170,8 +1170,8 @@ class Cake( } is DownloadMessage -> { - msg.data?.size?.takeIf { it > 0 }?.let { bytes -> - networkDebugSampler.recordDownload(bytes, Globals.curtime) + if (msg is DownloadMessage.Chunk && msg.data.size > 0) { + networkDebugSampler.recordDownload(msg.data.size, Globals.curtime) } when (val result = downloads.handle(msg)) { CakeDownloadSession.Result.Continue -> Unit diff --git a/cake/core/src/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt b/cake/core/src/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt index 05fa0e77..1cb62461 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt @@ -31,21 +31,24 @@ class CakeDownloadTransfer { fun handle(message: DownloadMessage): CakeDownloadTransferResult { val request = requireNotNull(activeRequest) { "No active download request" } - if (message.data == null) { - activeRequest = null - return CakeDownloadTransferResult.MissingOnServer(request) + val chunk = when (message) { + is DownloadMessage.Failure -> { + activeRequest = null + return CakeDownloadTransferResult.MissingOnServer(request) + } + is DownloadMessage.Chunk -> message } ensureParentDirectories(request) Files.write( request.tempPath, - message.data, + chunk.data.toByteArray(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND, ) - val percent = message.percentage.toInt() and 0xFF + val percent = chunk.percentage if (percent >= 100) { Files.move(request.tempPath, request.finalPath, StandardCopyOption.REPLACE_EXISTING) activeRequest = null 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 77d8edf4..7318c00b 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 @@ -732,7 +732,7 @@ class ClientEntityManager : Disposable { time = time.coerceIn(currentFrame.servertime - 100, currentFrame.servertime) // read areabits - System.arraycopy(msg.areaBits, 0, currentFrame.areabits, 0, msg.areaBits.size); + msg.areaBits.copyTo(currentFrame.areabits) } fun processPlayerInfoMessage(msg: PlayerInfoMessage) { 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 4d8e79ec..fa08cbd0 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 @@ -1141,9 +1141,7 @@ class Game3dScreen( } override fun processInventoryMessage(msg: InventoryMessage) { - for (i in 0..= MAX_ITEMS) { "Inventory must contain $MAX_ITEMS counts" } } + private val counts = IntArray(MAX_ITEMS) { inventory[it].toShort().toInt() } + + operator fun get(index: Int): Int = counts[index] + fun copyTo(destination: IntArray) { counts.copyInto(destination) } + + override fun equals(other: Any?): Boolean = other is InventoryMessage && counts.contentEquals(other.counts) + override fun hashCode(): Int = counts.contentHashCode() + override fun toString(): String = "InventoryMessage{inventory=${counts.contentToString()}}" +} 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 d65457d4..94d33b9c 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 @@ -2,6 +2,8 @@ package jake2.qcommon.network.messages.server import jake2.qcommon.Defines.MAX_CONFIGSTRINGS import jake2.qcommon.Defines.MAX_EDICTS +import jake2.qcommon.Defines.MAX_ITEMS +import jake2.qcommon.Defines.MAX_MAP_AREAS import jake2.qcommon.network.protocol.MessageReadException import jake2.qcommon.network.protocol.MessageReader import jake2.qcommon.network.protocol.MessageRejection @@ -28,6 +30,22 @@ object ServerMessageCodec { } is WeaponSoundMessage -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } is MuzzleFlash2Message -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } + is InventoryMessage -> repeat(MAX_ITEMS) { buffer.writeShort(message[it]) } + is DownloadMessage.Chunk -> { + buffer.writeShort(message.data.size) + buffer.writeByte(message.percentage.toByte()) + buffer.writeBytes(message.data.toByteArray()) + } + is DownloadMessage.Failure -> { buffer.writeShort(message.marker); buffer.writeByte(message.percentage.toByte()) } + is FrameHeaderMessage -> { + buffer.writeInt(message.frameNumber) + // What we are delta'ing from; -1 denotes a full frame. + buffer.writeInt(message.lastFrame) + // Rate dropped packets. + buffer.writeByte(message.suppressCount.toByte()) + buffer.writeByte(message.areaBits.size.toByte()) + buffer.writeBytes(message.areaBits.toByteArray()) + } DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> Unit is LegacyServerMessage -> message.writeLegacyProperties(buffer) else -> error("Unsupported server message ${message.javaClass.name}") @@ -44,6 +62,10 @@ object ServerMessageCodec { is ConfigStringMessage -> 4 + message.config.length is ServerDataMessage -> 14 + message.gameName.length + message.levelString.length is WeaponSoundMessage, is MuzzleFlash2Message -> 4 + is InventoryMessage -> 1 + 2 * MAX_ITEMS + is DownloadMessage.Chunk -> 4 + message.data.size + is DownloadMessage.Failure -> 4 + is FrameHeaderMessage -> 11 + message.areaBits.size DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 is LegacyServerMessage -> message.size else -> error("Unsupported server message ${message.javaClass.name}") @@ -73,6 +95,9 @@ object ServerMessageCodec { ) ServerMessageType.svc_muzzleflash -> WeaponSoundMessage(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) + ServerMessageType.svc_inventory -> InventoryMessage(IntArray(MAX_ITEMS) { buffer.readSignedShort() }) + ServerMessageType.svc_download -> readDownload(buffer) + ServerMessageType.svc_frame -> readFrameHeader(buffer) ServerMessageType.svc_configstring -> { val index = buffer.readUnsignedShort() if (index >= MAX_CONFIGSTRINGS) @@ -91,4 +116,26 @@ object ServerMessageCodec { throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number $index") return index } + + private fun readDownload(buffer: MessageReader): DownloadMessage { + val length = buffer.readSignedShort() + val percentage = buffer.readUnsignedByte() + return if (length == -1 || length == DownloadMessage.LEGACY_FAILURE_MARKER) { + DownloadMessage.Failure(length, percentage) + } else { + DownloadMessage.Chunk(buffer.readBytes(length), percentage) + } + } + + private fun readFrameHeader(buffer: MessageReader): 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() + val length = buffer.readUnsignedByte() + if (length > MAX_MAP_AREAS / 8) + throw MessageReadException(MessageRejection.INVALID_LENGTH, buffer.readcount - 1, "Invalid area bits length $length") + return FrameHeaderMessage(frameNumber, lastFrame, suppressCount, buffer.readBytes(length)) + } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt index 39793138..43daa3d9 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -42,6 +42,12 @@ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { /** Signed wire short, widened once at the boundary to -32768..32767, including negative protocol markers. */ fun readSignedShort(): Int = readShort().toInt() + /** Snapshot a bounded payload range before its packet buffer can be reused. */ + fun readBytes(length: Int): PacketBytes { + requireRemaining(this, length) + return PacketBytes.copyOfRange(data, readcount, length).also { readcount += length } + } + override fun readSignedByte(): Byte { requireRemaining(this, 1) return super.readSignedByte() diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt index c7f62fcb..176838d0 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt @@ -7,6 +7,9 @@ class PacketBytes private constructor(private val bytes: ByteArray) { val size: Int get() = bytes.size fun toByteArray(): ByteArray = bytes.copyOf() + /** Copy into engine-owned storage without exposing this payload's backing array. */ + @JvmOverloads + fun copyTo(destination: ByteArray, offset: Int = 0) { bytes.copyInto(destination, offset) } internal fun readOnlyBuffer(): ByteBuffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer() override fun equals(other: Any?): Boolean = other is PacketBytes && bytes.contentEquals(other.bytes) @@ -19,5 +22,11 @@ class PacketBytes private constructor(private val bytes: ByteArray) { require(length in 0..bytes.size) { "Payload length exceeds its backing array" } return PacketBytes(bytes.copyOf(length)) } + + @JvmStatic + fun copyOfRange(bytes: ByteArray, offset: Int, length: Int): PacketBytes { + require(offset >= 0 && length >= 0 && offset <= bytes.size - length) { "Payload range exceeds its backing array" } + return PacketBytes(bytes.copyOfRange(offset, offset + length)) + } } } diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 5b1c32e8..8c6acd1d 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -42,8 +42,8 @@ public static Stream createTestData() { testMessages.add(new Object[]{"server.NopMessage", NopMessage.INSTANCE}); testMessages.add(new Object[]{"server.EndOfServerPacketMessage", EndOfServerPacketMessage.INSTANCE}); // ordinary messages - testMessages.add(new Object[]{"server.DownloadMessage.data", new DownloadMessage(new byte[]{1, 2, 3}, (byte) 50)}); - testMessages.add(new Object[]{"server.DownloadMessage.empty", new DownloadMessage()}); + testMessages.add(new Object[]{"server.DownloadMessage.data", DownloadMessage.chunk(new byte[]{1, 2, 3}, 50)}); + testMessages.add(new Object[]{"server.DownloadMessage.empty", new DownloadMessage.Failure()}); testMessages.add(new Object[]{"server.DisconnectMessage", DisconnectMessage.INSTANCE}); testMessages.add(new Object[]{"server.ReconnectMessage", ReconnectMessage.INSTANCE}); testMessages.add(new Object[]{"server.LayoutMessage", new LayoutMessage("layout")}); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt index 5684fef1..529ff857 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt @@ -79,7 +79,7 @@ class MessageBodyCodecTest { fun `known download failure markers and explicit packet endings remain supported`() { for (length in listOf(-1, -32640)) { val result = decode(byteArrayOf(16, length.toByte(), (length shr 8).toByte(), 0)) as MessageDecodeResult.Decoded - assertNull((result.messages.single() as DownloadMessage).data) + assertEquals(length, (result.messages.single() as DownloadMessage.Failure).marker) } for (body in listOf(byteArrayOf(6), byteArrayOf(255.toByte()))) assertTrue((decode(body) as MessageDecodeResult.Decoded).messages.isEmpty()) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/OwnedMessagePayloadTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/OwnedMessagePayloadTest.kt new file mode 100644 index 00000000..a5e136a7 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/OwnedMessagePayloadTest.kt @@ -0,0 +1,117 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.MAX_ITEMS +import jake2.qcommon.Defines.MAX_MAP_AREAS +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class OwnedMessagePayloadTest { + private fun write(message: ServerMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray): ServerMessage { + val reader = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ServerMessageCodec.read(reader)!! + assertEquals(bytes.size, reader.readcount) + // Decoded payloads must no longer depend on even the decoder's owned mutable buffer. + reader.data.fill(0) + return message + } + + @Test + fun `inventory snapshots signed counts and ignores storage beyond the wire prefix`() { + val source = IntArray(MAX_ITEMS + 3) { it * 257 } + val expected = IntArray(MAX_ITEMS) { source[it].toShort().toInt() } + val message = InventoryMessage(source) + val bytes = write(message) + val hash = message.hashCode() + source.fill(99) + assertArrayEquals(bytes, write(message)) + assertEquals(hash, message.hashCode()) + assertEquals(InventoryMessage(expected), message) + assertEquals(message, read(bytes)) + val destination = IntArray(MAX_ITEMS) + message.copyTo(destination) + assertArrayEquals(expected, destination) + destination.fill(0) + assertArrayEquals(bytes, write(message)) + assertThrows(IllegalArgumentException::class.java) { InventoryMessage(IntArray(MAX_ITEMS - 1)) } + } + + @Test + fun `download chunks own input and exported bytes and use content equality`() { + val source = byteArrayOf(1, -1, 3) + val message = DownloadMessage.chunk(source, 128) + val bytes = write(message) + val hash = message.hashCode() + source.fill(0) + message.data.toByteArray().fill(0) + val destination = ByteArray(5) + message.data.copyTo(destination, 1) + assertArrayEquals(byteArrayOf(0, 1, -1, 3, 0), destination) + destination.fill(0) + assertEquals(hash, message.hashCode()) + assertEquals(DownloadMessage.chunk(byteArrayOf(1, -1, 3), 128), message) + assertArrayEquals(bytes, write(message)) + assertEquals(message, read(bytes)) + } + + @Test + fun `download failure markers and unsigned percentages survive re-encoding`() { + for (percent in 0..255) { + for (marker in listOf(-1, DownloadMessage.LEGACY_FAILURE_MARKER)) { + val bytes = byteArrayOf(16, marker.toByte(), (marker shr 8).toByte(), percent.toByte()) + val decoded = read(bytes) + assertEquals(DownloadMessage.Failure(marker, percent), decoded) + assertArrayEquals(bytes, write(decoded)) + } + val chunk = DownloadMessage.chunk(byteArrayOf(), percent) + assertEquals(chunk, read(write(chunk))) + } + } + + @Test + fun `frame visibility owns only the transmitted prefix and has content equality`() { + for (length in listOf(0, 1, MAX_MAP_AREAS / 8)) { + val source = ByteArray(MAX_MAP_AREAS / 8) { -1 } + val message = FrameHeaderMessage(7, -1, 255, length, source) + val bytes = write(message) + val hash = message.hashCode() + source.fill(0) + message.areaBits.toByteArray().fill(0) + assertEquals(length, message.areaBits.size) + assertEquals(hash, message.hashCode()) + assertArrayEquals(bytes, write(message.copy())) + assertEquals(message, read(bytes)) + assertEquals(message.hashCode(), read(bytes).hashCode()) + } + } + + @Test + fun `payload readers reject invalid ranges without consuming bytes and constructors enforce wire lengths`() { + for (length in listOf(-1, 4)) { + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(1, 2, 3)), 1) + val error = assertThrows(MessageReadException::class.java) { reader.readBytes(length) } + assertEquals(if (length < 0) MessageRejection.INVALID_LENGTH else MessageRejection.TRUNCATED, error.reason) + assertEquals(1, reader.readcount) + } + val reader = MessageReader(PacketBytes.copyOf(byteArrayOf(9, 1, 2, 8)), 1) + val bytes = reader.readBytes(2) + reader.data.fill(0) + assertArrayEquals(byteArrayOf(1, 2), bytes.toByteArray()) + assertEquals(3, reader.readcount) + assertThrows(IllegalArgumentException::class.java) { DownloadMessage.chunk(ByteArray(32768), 0) } + assertThrows(IllegalArgumentException::class.java) { DownloadMessage.chunk(byteArrayOf(), 256) } + assertThrows(IllegalArgumentException::class.java) { DownloadMessage.Failure(-2) } + assertThrows(IllegalArgumentException::class.java) { + FrameHeaderMessage(1, -1, 0, PacketBytes.copyOf(ByteArray(MAX_MAP_AREAS / 8 + 1))) + } + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index fd6e2cf3..830881e9 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1554,11 +1554,11 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.downloadcount += packet var size = client.downloadsize if (size == 0) size = 1 - val percent = (client.downloadcount * 100 / size).toByte() + val percent = (client.downloadcount.toLong() * 100 / size).toInt() val data = ByteArray(packet) System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet) - client.netchan.reliablePending.add(DownloadMessage(data, percent)) + client.netchan.reliablePending.add(DownloadMessage.chunk(data, percent)) if (client.downloadcount == client.downloadsize) { client.download = null @@ -1599,7 +1599,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // refuse - client.netchan.reliablePending.add(DownloadMessage()) + client.netchan.reliablePending.add(DownloadMessage.Failure()) return } @@ -1608,7 +1608,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // The client waits for either data or an explicit refusal. Silently // returning here leaves precache stuck waiting for a download forever. if (client.download == null) { - client.netchan.reliablePending.add(DownloadMessage()) + client.netchan.reliablePending.add(DownloadMessage.Failure()) return } @@ -1625,7 +1625,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // refuse - client.netchan.reliablePending.add(DownloadMessage()) + client.netchan.reliablePending.add(DownloadMessage.Failure()) return } From a79aa00613a6c5dedd197be10f5b97ba0c85874b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 22:28:29 +0100 Subject: [PATCH 42/57] Make sound messages immutable with owned vector positions --- .../java/jake2/server/GameImportsTest.java | 12 +- .../ingame/IngameSoundMessageHandler.kt | 4 +- .../messages/server/ServerMessage.java | 3 - .../network/messages/server/SoundMessage.java | 193 ------------------ .../messages/server/ServerMessageCodec.kt | 35 +++- .../network/messages/server/SoundMessage.kt | 73 +++++++ .../qcommon/network/protocol/MessageReader.kt | 4 + .../messages/NetworkMessageSizeTest.java | 2 +- .../network/protocol/MessageReencodingTest.kt | 17 +- .../network/protocol/SoundMessageCodecTest.kt | 74 +++++++ .../java/jake2/server/GameImportsImpl.java | 8 +- 11 files changed, 203 insertions(+), 222 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SoundMessage.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/SoundMessageCodecTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index c2405b29..27107d1e 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -251,12 +251,12 @@ public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { var sounds = List.copyOf(firstClient.unreliable); Assertions.assertEquals(2, sounds.size()); SoundMessage earlier = (SoundMessage) sounds.get(0); - Assertions.assertArrayEquals(new float[]{12, 0, 0}, earlier.origin); - Assertions.assertArrayEquals(new float[]{42, 0, 0}, ((SoundMessage) sounds.get(1)).origin); + Assertions.assertArrayEquals(new float[]{12, 0, 0}, earlier.getOrigin().toArray()); + Assertions.assertArrayEquals(new float[]{42, 0, 0}, ((SoundMessage) sounds.get(1)).getOrigin().toArray()); Assertions.assertEquals(Defines.SND_ENT | Defines.SND_POS | Defines.SND_VOLUME - | Defines.SND_ATTENUATION | Defines.SND_OFFSET, earlier.flags); - Assertions.assertEquals(entity.index, earlier.entityIndex); - Assertions.assertEquals(Defines.CHAN_WEAPON, earlier.sendchan); + | Defines.SND_ATTENUATION | Defines.SND_OFFSET, earlier.getFlags()); + Assertions.assertEquals(entity.index, earlier.getEntityIndex()); + Assertions.assertEquals(Defines.CHAN_WEAPON, earlier.getChannel()); float[] explicitOrigin = {1, 2, 3}; first.positioned_sound(explicitOrigin, entity, Defines.CHAN_RELIABLE | Defines.CHAN_VOICE, @@ -264,7 +264,7 @@ public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { explicitOrigin[0] = 99; Assertions.assertEquals(1, firstClient.netchan.reliablePending.size()); SoundMessage reliable = (SoundMessage) firstClient.netchan.reliablePending.iterator().next(); - Assertions.assertArrayEquals(new float[]{1, 2, 3}, reliable.origin); + Assertions.assertArrayEquals(new float[]{1, 2, 3}, reliable.getOrigin().toArray()); Assertions.assertTrue(secondClient.unreliable.isEmpty()); Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); } finally { 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 f012f069..dee0cefb 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 @@ -42,7 +42,7 @@ internal class IngameSoundMessageHandler( attenuation = msg.attenuation, origin = origin, entityIndex = msg.entityIndex, - channel = msg.sendchan, + channel = msg.channel, timeOffsetSeconds = msg.timeOffset, ) ) @@ -248,7 +248,7 @@ internal class IngameSoundMessageHandler( private fun resolveExplicitSoundOrigin(msg: SoundMessage): Vector3? { val rawOrigin = msg.origin ?: return null - return Vector3(rawOrigin[0], rawOrigin[1], rawOrigin[2]) + return Vector3(rawOrigin.x, rawOrigin.y, rawOrigin.z) } /** diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 398de441..5e25e0d2 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -145,9 +145,6 @@ static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_ switch (type) { default: throw new MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode " + type); - case svc_sound: - msg = new SoundMessage(); - break; case svc_playerinfo: msg = new PlayerInfoMessage(); break; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java deleted file mode 100644 index 67dc6343..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java +++ /dev/null @@ -1,193 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.Defines; -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; - -/** - * Each entity can have eight independent sound sources, like voice, - * weapon, feet, etc. - *

- * If channel & 8, the sound will be sent to everyone, not just - * things in the PHS. - *

- * FIXME: if entity isn't in PHS, they must be forced to be sent or have the origin explicitly sent. - *

- * Channel 0 is an auto-allocate channel, the others override anything - * already running on that entity/channel pair. - *

- * An attenuation of 0 will play full volume everywhere in the level. - * Larger attenuation will drop off. (max 4 attenuation) - *

- * Timeofs can range from 0.0 to 0.255 to cause sounds to be started - * later in the frame than they normally would. - *

- * If origin is null, the origin is determined from the entity origin - * or the midpoint of the entity box for bmodels. - * ================== - */ -public class SoundMessage extends LegacyServerMessage { - public int flags; - public int soundIndex; - public float volume; - public float attenuation; - /** Integer wire milliseconds avoid losing a received value when converting through float seconds. */ - private int timeOffsetMillis; - - public float getTimeOffset() { return timeOffsetMillis / 1000.0f; } - - /** Outbound sounds retain the protocol's existing truncation from seconds to milliseconds. */ - public void setTimeOffset(float seconds) { timeOffsetMillis = (int) (seconds * 1000); } - /** Logical channel (0..7); the wire packs entityIndex in the upper bits. */ - public int sendchan; - public float[] origin; - - public int entityIndex; - - public SoundMessage() { - super(ServerMessageType.svc_sound); - } - - public SoundMessage(int flags, int soundIndex, float volume, float attenuation, float timeOffset, int sendchan, float[] origin) { - this(); - this.flags = flags; - this.soundIndex = soundIndex; - this.volume = volume; - this.attenuation = attenuation; - setTimeOffset(timeOffset); - this.entityIndex = (flags & Defines.SND_ENT) != 0 ? sendchan >> 3 : 0; - this.sendchan = (flags & Defines.SND_ENT) != 0 ? sendchan & 7 : 0; - this.origin = origin; - } - - - // todo: sync read & write logic (make flags private) - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeByte((byte) flags); - buffer.writeByte((byte) soundIndex); - - if ((flags & Defines.SND_VOLUME) != 0) - buffer.writeByte((byte) (volume * 255)); - - if ((flags & Defines.SND_ATTENUATION) != 0) - buffer.writeByte((byte) (attenuation * 64)); - - if ((flags & Defines.SND_OFFSET) != 0) - buffer.writeByte((byte) timeOffsetMillis); - - if ((flags & Defines.SND_ENT) != 0) - buffer.writeShort((entityIndex << 3) | (sendchan & 7)); - - if ((flags & Defines.SND_POS) != 0) - buffer.writePos(origin); - - } - - @Override - public void parse(sizebuf_t buffer) { - this.flags = buffer.readByte(); - this.soundIndex = buffer.readByte(); - - if ((flags & Defines.SND_VOLUME) != 0) - volume = buffer.readByte() / 255.0f; - else - volume = Defines.DEFAULT_SOUND_PACKET_VOLUME; - - if ((flags & Defines.SND_ATTENUATION) != 0) - attenuation = buffer.readByte() / 64.0f; - else - attenuation = Defines.DEFAULT_SOUND_PACKET_ATTENUATION; - - if ((flags & Defines.SND_OFFSET) != 0) - timeOffsetMillis = buffer.readByte(); - else - timeOffsetMillis = 0; - - if ((flags & Defines.SND_ENT) != 0) { // entity relative - sendchan = buffer.readShort(); - entityIndex = sendchan >> 3; - validateEntityIndex(buffer, entityIndex); - - sendchan &= 7; - } else { - entityIndex = 0; - sendchan = 0; - } - - if ((flags & Defines.SND_POS) != 0) { // positioned in space - float[] pos_v = new float[3]; - buffer.readPos(pos_v); - // is ok. sound driver copies - origin = pos_v; - } else - // use entity number - origin = null; - - } - - @Override - public int getSize() { - int result = 3; - if ((flags & Defines.SND_VOLUME) != 0) - result += 1; - - if ((flags & Defines.SND_ATTENUATION) != 0) - result += 1; - - if ((flags & Defines.SND_OFFSET) != 0) - result += 1; - if ((flags & Defines.SND_ENT) != 0) { // entity reletive - result += 2; - } - if ((flags & Defines.SND_POS) != 0) { // positioned in space - result += 6; - } - return result; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - SoundMessage that = (SoundMessage) o; - - if (flags != that.flags) return false; - if (soundIndex != that.soundIndex) return false; - if (Float.compare(that.volume, volume) != 0) return false; - if (Float.compare(that.attenuation, attenuation) != 0) return false; - if (timeOffsetMillis != that.timeOffsetMillis) return false; - if (sendchan != that.sendchan) return false; - if (entityIndex != that.entityIndex) return false; - return Arrays.equals(origin, that.origin); - } - - @Override - public int hashCode() { - int result = flags; - result = 31 * result + soundIndex; - result = 31 * result + (volume != +0.0f ? Float.floatToIntBits(volume) : 0); - result = 31 * result + (attenuation != +0.0f ? Float.floatToIntBits(attenuation) : 0); - result = 31 * result + timeOffsetMillis; - result = 31 * result + sendchan; - result = 31 * result + Arrays.hashCode(origin); - result = 31 * result + entityIndex; - return result; - } - - @Override - public String toString() { - return "SoundMessage{" + - "flags=" + flags + - ", soundIndex=" + soundIndex + - ", volume=" + volume + - ", attenuation=" + attenuation + - ", timeOffset=" + getTimeOffset() + - ", sendchan=" + sendchan + - ", origin=" + Arrays.toString(origin) + - ", entityIndex=" + entityIndex + - '}'; - } -} 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 94d33b9c..06ab9d4b 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 @@ -1,9 +1,6 @@ package jake2.qcommon.network.messages.server -import jake2.qcommon.Defines.MAX_CONFIGSTRINGS -import jake2.qcommon.Defines.MAX_EDICTS -import jake2.qcommon.Defines.MAX_ITEMS -import jake2.qcommon.Defines.MAX_MAP_AREAS +import jake2.qcommon.Defines.* import jake2.qcommon.network.protocol.MessageReadException import jake2.qcommon.network.protocol.MessageReader import jake2.qcommon.network.protocol.MessageRejection @@ -30,6 +27,15 @@ object ServerMessageCodec { } is WeaponSoundMessage -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } is MuzzleFlash2Message -> { buffer.writeShort(message.entityIndex); buffer.writeByte(message.flashType.toByte()) } + is SoundMessage -> { + buffer.writeByte(message.flags.toByte()) + buffer.writeByte(message.soundIndex.toByte()) + message.volumeByte?.let { buffer.writeByte(it.toByte()) } + message.attenuationByte?.let { buffer.writeByte(it.toByte()) } + message.timeOffsetMillis?.let { buffer.writeByte(it.toByte()) } + message.source?.let { buffer.writeShort((it.entityIndex shl 3) or it.channel) } + message.origin?.let { buffer.writeCoord(it.x); buffer.writeCoord(it.y); buffer.writeCoord(it.z) } + } is InventoryMessage -> repeat(MAX_ITEMS) { buffer.writeShort(message[it]) } is DownloadMessage.Chunk -> { buffer.writeShort(message.data.size) @@ -62,6 +68,9 @@ object ServerMessageCodec { is ConfigStringMessage -> 4 + message.config.length is ServerDataMessage -> 14 + message.gameName.length + message.levelString.length is WeaponSoundMessage, is MuzzleFlash2Message -> 4 + is SoundMessage -> 3 + (if (message.volumeByte != null) 1 else 0) + + (if (message.attenuationByte != null) 1 else 0) + (if (message.timeOffsetMillis != null) 1 else 0) + + (if (message.source != null) 2 else 0) + (if (message.origin != null) 6 else 0) is InventoryMessage -> 1 + 2 * MAX_ITEMS is DownloadMessage.Chunk -> 4 + message.data.size is DownloadMessage.Failure -> 4 @@ -95,6 +104,7 @@ object ServerMessageCodec { ) ServerMessageType.svc_muzzleflash -> WeaponSoundMessage(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) + ServerMessageType.svc_sound -> readSound(buffer) ServerMessageType.svc_inventory -> InventoryMessage(IntArray(MAX_ITEMS) { buffer.readSignedShort() }) ServerMessageType.svc_download -> readDownload(buffer) ServerMessageType.svc_frame -> readFrameHeader(buffer) @@ -117,6 +127,23 @@ object ServerMessageCodec { return index } + private fun readSound(buffer: MessageReader): SoundMessage { + val flags = buffer.readUnsignedByte() + val soundIndex = buffer.readUnsignedByte() + val volume = if (flags and SND_VOLUME != 0) buffer.readUnsignedByte() else null + val attenuation = if (flags and SND_ATTENUATION != 0) buffer.readUnsignedByte() else null + val offset = if (flags and SND_OFFSET != 0) buffer.readUnsignedByte() else null + val source = if (flags and SND_ENT != 0) { + val packed = buffer.readUnsignedShort() + val entity = packed ushr 3 + if (entity >= MAX_EDICTS) + throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid sound entity number $entity") + SoundSource(entity, packed and 7) + } else null + val origin = if (flags and SND_POS != 0) buffer.readPosition() else null + return SoundMessage(soundIndex, volume, attenuation, offset, source, origin, flags and SoundMessage.KNOWN_FLAGS.inv()) + } + private fun readDownload(buffer: MessageReader): DownloadMessage { val length = buffer.readSignedShort() val percentage = buffer.readUnsignedByte() diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SoundMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SoundMessage.kt new file mode 100644 index 00000000..78d86c1f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SoundMessage.kt @@ -0,0 +1,73 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f + +/** Logical channel (0..7); the wire packs the entity index in the upper bits. */ +data class SoundSource(val entityIndex: Int, val channel: Int) { + init { + require(entityIndex in 0 until MAX_EDICTS) { "Invalid sound entity index" } + require(channel in 0..7) { "Invalid sound channel" } + } +} + +/** + * Each entity can have eight independent sound sources, like voice, weapon, feet, etc. + * If channel & 8, the sound will be sent to everyone, not just things in the PHS. + * FIXME: if an entity isn't in PHS, it must be forced to be sent or have the origin explicitly sent. + * + * Channel 0 is an auto-allocate channel; the others override anything already running on that entity/channel pair. + * An attenuation of 0 plays at full volume everywhere in the level. Larger attenuation drops off (max 4 attenuation). + * Time offsets range from 0.0 to 0.255 seconds, starting sounds later in the frame than they normally would. + * If [origin] is null, use the entity origin or the midpoint of the entity box for bmodels. + * + * Optional bytes retain field presence, including explicitly transmitted defaults and zero values. + * Integer wire milliseconds avoid losing a received value through float seconds. + * The position is immutable; audio consumers copy it into their own vector storage. + */ +data class SoundMessage( + val soundIndex: Int, + val volumeByte: Int? = null, + val attenuationByte: Int? = null, + val timeOffsetMillis: Int? = null, + val source: SoundSource? = null, + val origin: Vector3f? = null, + val extraFlags: Int = 0, +) : ServerMessage(ServerMessageType.svc_sound) { + init { + require(soundIndex in 0..255) { "Sound index exceeds unsigned byte range" } + require(volumeByte == null || volumeByte in 0..255) { "Sound volume exceeds unsigned byte range" } + require(attenuationByte == null || attenuationByte in 0..255) { "Sound attenuation exceeds unsigned byte range" } + require(timeOffsetMillis == null || timeOffsetMillis in 0..255) { "Sound offset exceeds unsigned byte range" } + require(extraFlags in 0..255 && extraFlags and KNOWN_FLAGS == 0) { "Extra flags overlap sound fields" } + } + + val flags: Int get() = extraFlags or + (if (volumeByte != null) SND_VOLUME else 0) or + (if (attenuationByte != null) SND_ATTENUATION else 0) or + (if (timeOffsetMillis != null) SND_OFFSET else 0) or + (if (source != null) SND_ENT else 0) or + (if (origin != null) SND_POS else 0) + val volume: Float get() = volumeByte?.div(255f) ?: DEFAULT_SOUND_PACKET_VOLUME + val attenuation: Float get() = attenuationByte?.div(64f) ?: DEFAULT_SOUND_PACKET_ATTENUATION + val timeOffset: Float get() = (timeOffsetMillis ?: 0) / 1000f + val entityIndex: Int get() = source?.entityIndex ?: 0 + val channel: Int get() = source?.channel ?: 0 + + companion object { + const val KNOWN_FLAGS = SND_VOLUME or SND_ATTENUATION or SND_OFFSET or SND_ENT or SND_POS + + /** Snapshot game arrays and retain the protocol's existing truncation/wrapping of outbound float values. */ + @JvmStatic + fun fromGame(flags: Int, soundIndex: Int, volume: Float, attenuation: Float, timeOffset: Float, + packedChannel: Int, origin: FloatArray?): SoundMessage = SoundMessage( + soundIndex = soundIndex and 255, + volumeByte = if (flags and SND_VOLUME != 0) (volume * 255).toInt() and 255 else null, + attenuationByte = if (flags and SND_ATTENUATION != 0) (attenuation * 64).toInt() and 255 else null, + timeOffsetMillis = if (flags and SND_OFFSET != 0) (timeOffset * 1000).toInt() and 255 else null, + source = if (flags and SND_ENT != 0) SoundSource(packedChannel ushr 3, packedChannel and 7) else null, + origin = if (flags and SND_POS != 0) Vector3f(requireNotNull(origin) { "Positioned sound requires an origin" }) else null, + extraFlags = flags and KNOWN_FLAGS.inv() and 255, + ) + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt index 43daa3d9..78d9fbb4 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -2,6 +2,7 @@ package jake2.qcommon.network.protocol import jake2.qcommon.Defines import jake2.qcommon.Globals +import jake2.qcommon.math.Vector3f import jake2.qcommon.sizebuf_t import jake2.qcommon.network.transport.PacketBytes @@ -48,6 +49,9 @@ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { return PacketBytes.copyOfRange(data, readcount, length).also { readcount += length } } + /** Three signed fixed-point coordinates, each stored at one eighth of a world unit. */ + fun readPosition(): Vector3f = Vector3f(readCoord(), readCoord(), readCoord()) + override fun readSignedByte(): Byte { requireRemaining(this, 1) return super.readSignedByte() diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 8c6acd1d..eb261a1b 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -66,7 +66,7 @@ public static Stream createTestData() { testMessages.add(new Object[]{"server.BeamOffsetTEMessage", new BeamOffsetTEMessage(TE_GRAPPLE_CABLE, 8, new float[3], new float[3], new float[3])}); testMessages.add(new Object[]{"server.PointDirectionTEMessage", new PointDirectionTEMessage(TE_GUNSHOT, new float[3], new float[]{-0.525731f, 0.000000f, 0.850651f})}); testMessages.add(new Object[]{"server.TrailTEMessage", new TrailTEMessage(TE_BUBBLETRAIL, new float[3], new float[3])}); - testMessages.add(new Object[]{"server.SoundMessage", new SoundMessage(SND_VOLUME | SND_ATTENUATION | SND_OFFSET | SND_ENT | SND_POS, 2, 1, 2, 0.2f, 1, new float[]{1, 2, 3})}); + testMessages.add(new Object[]{"server.SoundMessage", SoundMessage.fromGame(SND_VOLUME | SND_ATTENUATION | SND_OFFSET | SND_ENT | SND_POS, 2, 1, 2, 0.2f, 1, new float[]{1, 2, 3})}); // delta compressed testMessages.add(new Object[]{"server.SpawnBaselineMessage.empty", new SpawnBaselineMessage(new entity_state_t(1))}); entity_state_t entityState = new entity_state_t(1); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt index 388cbd3b..93280d6e 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt @@ -84,27 +84,30 @@ class MessageReencodingTest { @Test fun `sound constructor and parser use the same entity and channel representation`() { - for (flags in 0..31) for (index in listOf(0, 3, MAX_EDICTS - 1)) for (channel in 0..7) { - val message = SoundMessage(flags, 2, 1f, 2f, 0.2f, (index shl 3) or channel, + for (flags in 0..255) for (index in listOf(0, 3, MAX_EDICTS - 1)) for (channel in 0..7) { + val message = SoundMessage.fromGame(flags, 2, 1f, 2f, 0.2f, (index shl 3) or channel, floatArrayOf(1f, -2f, 3f)) val decoded = parse(write(message)) as SoundMessage assertEquals(if (flags and SND_ENT != 0) index else 0, decoded.entityIndex) - assertEquals(if (flags and SND_ENT != 0) channel else 0, decoded.sendchan) + assertEquals(if (flags and SND_ENT != 0) channel else 0, decoded.channel) + assertEquals(flags, decoded.flags) + assertEquals(message, decoded) roundTrip(message) } } @Test - fun `outbound sound delay retains truncation and edited decoded delays use the same rule`() { - val created = SoundMessage(SND_OFFSET, 2, 1f, 1f, 0.251f, 0, null) + fun `outbound sound delay retains truncation while decoded milliseconds remain exact`() { + val created = SoundMessage.fromGame(SND_OFFSET, 2, 1f, 1f, 0.251f, 0, null) val expected = (0.251f * 1000).toInt() assertEquals(expected / 1000f, created.timeOffset) assertEquals(expected.toByte(), write(created).last()) val decoded = parse(byteArrayOf(9, SND_OFFSET.toByte(), 2, 251.toByte())) as SoundMessage assertEquals(0.251f, decoded.timeOffset) assertEquals(251.toByte(), write(decoded).last()) - decoded.timeOffset = 0.1239f - assertEquals(123.toByte(), write(decoded).last()) + val edited = decoded.copy(timeOffsetMillis = 123) + assertEquals(123.toByte(), write(edited).last()) + assertEquals(251.toByte(), write(decoded).last()) } @Test diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/SoundMessageCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/SoundMessageCodecTest.kt new file mode 100644 index 00000000..33c5c950 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/SoundMessageCodecTest.kt @@ -0,0 +1,74 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SoundMessageCodecTest { + private fun write(message: SoundMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray): SoundMessage { + val reader = MessageReader(PacketBytes.copyOf(bytes), 0) + val result = ServerMessageCodec.read(reader) as SoundMessage + assertEquals(bytes.size, reader.readcount) + reader.data.fill(0) + return result + } + + @Test + fun `sound positions own their source and copies preserve independent field presence`() { + val position = floatArrayOf(1f, -2f, 3f) + val message = SoundMessage.fromGame(SND_POS or SND_ENT, 7, 0.25f, 2f, 0.2f, (3 shl 3) or 7, position) + val bytes = write(message) + val hash = message.hashCode() + position.fill(99f) + message.origin!!.toArray().fill(99f) + assertArrayEquals(bytes, write(message)) + assertEquals(hash, message.hashCode()) + assertEquals(Vector3f(1f, -2f, 3f), message.origin) + assertEquals(message, read(bytes)) + assertEquals(DEFAULT_SOUND_PACKET_VOLUME, message.volume) + assertEquals(DEFAULT_SOUND_PACKET_ATTENUATION, message.attenuation) + assertEquals(0f, message.timeOffset) + val explicitDefault = message.copy(volumeByte = 255) + assertEquals(message.volume, explicitDefault.volume) + assertNotEquals(message, explicitDefault) + assertEquals(message.flags or SND_VOLUME, explicitDefault.flags) + assertEquals(explicitDefault, read(write(explicitDefault))) + val changed = message.copy(volumeByte = 0, source = null, origin = null, timeOffsetMillis = 0) + assertEquals(SND_VOLUME or SND_OFFSET, changed.flags) + assertEquals(changed, read(write(changed))) + assertArrayEquals(bytes, write(message)) + } + + @Test + fun `outbound sound scalar and position quantization retains legacy truncation and wrapping`() { + val message = SoundMessage.fromGame(SND_VOLUME or SND_ATTENUATION or SND_OFFSET or SND_POS, + 255, 0.5f, 4f, 0.251f, 0, floatArrayOf(-0.124f, 0.249f, 4096f)) + // Half volume truncates to 127; attenuation 4 wraps its scaled byte to 0. + // Coordinate conversion truncates toward zero and wraps a signed short at 4096 units. + val bytes = byteArrayOf(9, (SND_VOLUME or SND_ATTENUATION or SND_OFFSET or SND_POS).toByte(), -1, + 127, 0, (0.251f * 1000).toInt().toByte(), 0, 0, 1, 0, 0, 128.toByte()) + assertArrayEquals(bytes, write(message)) + val decoded = read(bytes) + assertEquals(Vector3f(0f, 0.125f, -4096f), decoded.origin) + assertArrayEquals(bytes, write(decoded)) + } + + @Test + fun `every signed coordinate value survives sound decode and re-encode`() { + for (coordinate in Short.MIN_VALUE..Short.MAX_VALUE) { + val bytes = byteArrayOf(9, SND_POS.toByte(), 1, coordinate.toByte(), (coordinate shr 8).toByte(), 0, 0, 0, 0) + assertArrayEquals(bytes, write(read(bytes)), "Coordinate $coordinate") + } + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index f2618162..723b58b9 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -450,11 +450,6 @@ public void positioned_sound(float[] origin, ServerEntity ent, int channel, } } - // Queued messages own their position until the transport serializes them. - else { - origin = origin.clone(); - } - // if the sound doesn't attenuate,send it to everyone // (global radio chatter, voiceovers, etc) if (attenuation == Defines.ATTN_NONE) @@ -473,7 +468,8 @@ public void positioned_sound(float[] origin, ServerEntity ent, int channel, else multicastType = MulticastTypes.MULTICAST_ALL; } - multicastMessage(origin, new SoundMessage(flags, soundindex, volume, attenuation, timeofs, sendchan, origin), multicastType); + // Queued messages own an immutable position snapshot until the transport serializes them. + multicastMessage(origin, SoundMessage.fromGame(flags, soundindex, volume, attenuation, timeofs, sendchan, origin), multicastType); } /* From 4204a8600912b586a06496f7b12c80f26a7acd6e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 22:44:40 +0100 Subject: [PATCH 43/57] Convert temporary entities to sealed immutable Kotlin values --- .../ingame/effects/ClientEffectsSystem.kt | 57 ++++---- .../messages/server/BeamOffsetTEMessage.java | 81 ---------- .../messages/server/BeamTEMessage.java | 93 ------------ .../server/PointDirectionTEMessage.java | 91 ------------ .../messages/server/PointTEMessage.java | 108 -------------- .../messages/server/ServerMessage.java | 18 +-- .../messages/server/SplashTEMessage.java | 96 ------------ .../network/messages/server/TEMessage.java | 43 ------ .../messages/server/TrailTEMessage.java | 81 ---------- .../messages/server/ServerMessageCodec.kt | 3 + .../messages/server/TemporaryEntityCodec.kt | 59 ++++++++ .../server/TemporaryEntityMessages.kt | 86 +++++++++++ .../network/protocol/DirectionCodec.kt | 29 ++++ .../protocol/TemporaryEntityCodecTest.kt | 138 ++++++++++++++++++ 14 files changed, 346 insertions(+), 637 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamOffsetTEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamTEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/PointDirectionTEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/PointTEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/SplashTEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/TrailTEMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.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 da090a30..9ec70346 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 @@ -10,6 +10,7 @@ import jake2.qcommon.Com import jake2.qcommon.Defines import jake2.qcommon.Globals import jake2.qcommon.M_Flash +import jake2.qcommon.math.Vector3f import jake2.qcommon.network.messages.server.BeamOffsetTEMessage import jake2.qcommon.network.messages.server.BeamTEMessage import jake2.qcommon.network.messages.server.MuzzleFlash2Message @@ -125,7 +126,8 @@ class ClientEffectsSystem( /** * Handles temp-entity messages (`svc_temp_entity`) already decoded into typed message classes. * - * Unknown styles inside known message types are currently ignored silently by style branch fall-through. + * The codec validates subtype/layout combinations. Valid styles without an implemented renderer + * are still ignored silently by style branch fall-through. */ fun processTempEntityMessage(msg: TEMessage) { when (msg) { @@ -135,7 +137,6 @@ class ClientEffectsSystem( is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) is PointTEMessage -> handlePointMessage(msg) - else -> Com.Warn("Unhandled temp entity message class: ${msg.javaClass.simpleName}") } } @@ -269,7 +270,7 @@ class ClientEffectsSystem( } private fun handlePointDirectionMessage(msg: PointDirectionTEMessage) { - val position = toVector3(msg.position) ?: return + val position = toVector3(msg.position) when (msg.style) { Defines.TE_BLOOD, Defines.TE_MOREBLOOD, @@ -280,7 +281,7 @@ class ClientEffectsSystem( } emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = when (msg.style) { Defines.TE_MOREBLOOD -> 250 Defines.TE_GREENBLOOD -> 30 @@ -299,7 +300,7 @@ class ClientEffectsSystem( } emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = count, paletteBase = paletteBase, ) @@ -318,7 +319,7 @@ class ClientEffectsSystem( } emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = 40, paletteBase = paletteBase, ) @@ -328,7 +329,7 @@ class ClientEffectsSystem( Defines.TE_HEATBEAM_SPARKS -> { emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = 50, paletteBase = HEATBEAM_SPARK_PARTICLE_BASE_COLOR, ) @@ -338,7 +339,7 @@ class ClientEffectsSystem( Defines.TE_HEATBEAM_STEAM -> { emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = 20, paletteBase = HEATBEAM_STEAM_PARTICLE_BASE_COLOR, ) @@ -348,7 +349,7 @@ class ClientEffectsSystem( Defines.TE_ELECTRIC_SPARKS -> { emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = 40, paletteBase = ELECTRIC_SPARK_PARTICLE_BASE_COLOR, ) @@ -358,7 +359,7 @@ class ClientEffectsSystem( Defines.TE_SHOTGUN -> { emitPaletteImpactParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = 20, paletteBase = SHOTGUN_PARTICLE_BASE_COLOR, ) @@ -366,19 +367,19 @@ class ClientEffectsSystem( } Defines.TE_BLASTER -> { - spawnBlasterImpact(position, msg.direction, skinIndex = 0) + 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, skinIndex = 1) + 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, skinIndex = 2) + spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 2) spawnDynamicLight(position, 150f, 0.19f, 0.41f, 0.75f) playEffectSound("sound/weapons/lashit.wav", position) } @@ -386,8 +387,8 @@ class ClientEffectsSystem( } private fun handleTrailMessage(msg: TrailTEMessage) { - val start = toVector3(msg.position) ?: return - val end = toVector3(msg.destination) ?: return + val start = toVector3(msg.position) + val end = toVector3(msg.destination) when (msg.style) { Defines.TE_BUBBLETRAIL -> { emitSegmentTrailParticles( @@ -415,7 +416,7 @@ class ClientEffectsSystem( Defines.TE_BLUEHYPERBLASTER -> { emitPaletteImpactParticles( origin = start, - direction = msg.destination, + direction = msg.destination.toArray(), count = 40, paletteBase = BLUE_HYPERBLASTER_PARTICLE_BASE_COLOR, ) @@ -464,13 +465,13 @@ class ClientEffectsSystem( } private fun handleSplashMessage(msg: SplashTEMessage) { - val position = toVector3(msg.position) ?: return + 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, + direction = msg.direction.toArray(), count = msg.count.coerceIn(4, 64), paletteBase = splashPaletteBase, ) @@ -482,7 +483,7 @@ class ClientEffectsSystem( Defines.TE_LASER_SPARKS -> { emitLegacyPaletteParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = msg.count, paletteIndex = msg.param, gravity = -320f, @@ -492,7 +493,7 @@ class ClientEffectsSystem( Defines.TE_WELDING_SPARKS -> { emitLegacyPaletteParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = msg.count, paletteIndex = msg.param, gravity = -320f, @@ -510,7 +511,7 @@ class ClientEffectsSystem( Defines.TE_TUNNEL_SPARKS -> { emitLegacyPaletteParticles( origin = position, - direction = msg.direction, + direction = msg.direction.toArray(), count = msg.count, paletteIndex = msg.param, gravity = 320f, @@ -520,7 +521,7 @@ class ClientEffectsSystem( } private fun handlePointMessage(msg: PointTEMessage) { - val position = toVector3(msg.position) ?: return + val position = toVector3(msg.position) when (msg.style) { Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, @@ -648,8 +649,8 @@ class ClientEffectsSystem( 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, - end = msg.destination, + start = msg.origin.toArray(), + end = msg.destination.toArray(), offset = null, durationMs = 200, ) @@ -660,9 +661,9 @@ class ClientEffectsSystem( if (msg.style == Defines.TE_GRAPPLE_CABLE) { spawnSegmentBeam( modelPath = "models/ctf/segment/tris.md2", - start = msg.origin, - end = msg.destination, - offset = msg.offset, + start = msg.origin.toArray(), + end = msg.destination.toArray(), + offset = msg.offset.toArray(), durationMs = 200, ) } @@ -1606,6 +1607,8 @@ class ClientEffectsSystem( } } + private fun toVector3(value: Vector3f): Vector3 = Vector3(value.x, value.y, value.z) + private fun toVector3(value: FloatArray?): Vector3? { if (value == null || value.size < 3) { return null diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamOffsetTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamOffsetTEMessage.java deleted file mode 100644 index 1388456f..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamOffsetTEMessage.java +++ /dev/null @@ -1,81 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.TE_GRAPPLE_CABLE; - -public class BeamOffsetTEMessage extends BeamTEMessage { - public static final Collection SUBTYPES = Set.of( - TE_GRAPPLE_CABLE - ); - - public float[] offset; - - public BeamOffsetTEMessage(int style) { - super(style); - } - - public BeamOffsetTEMessage(int style, int ownerIndex, float[] origin, float[] destination, float[] offset) { - this(style); - this.ownerIndex = ownerIndex; - this.origin = origin; - this.destination = destination; - this.offset = offset; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writePos(offset); - } - - @Override - public void parse(sizebuf_t buffer) { - super.parse(buffer); - offset = new float[3]; - buffer.readPos(offset); - } - - @Override - public int getSize() { - return super.getSize() + 6; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "BeamOffsetTEMessage{" + - "offset=" + Arrays.toString(offset) + - ", ownerIndex=" + ownerIndex + - ", origin=" + Arrays.toString(origin) + - ", destination=" + Arrays.toString(destination) + - ", style=" + style + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - if (!super.equals(o)) return false; - - BeamOffsetTEMessage that = (BeamOffsetTEMessage) o; - - return Arrays.equals(offset, that.offset); - } - - @Override - public int hashCode() { - int result = super.hashCode(); - result = 31 * result + Arrays.hashCode(offset); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamTEMessage.java deleted file mode 100644 index a42770fc..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamTEMessage.java +++ /dev/null @@ -1,93 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.TE_MEDIC_CABLE_ATTACK; -import static jake2.qcommon.Defines.TE_PARASITE_ATTACK; - -/** - * TE_PARASITE_ATTACK - * TE_MEDIC_CABLE_ATTACK - */ -public class BeamTEMessage extends TEMessage { - - public static final Collection SUBTYPES = Set.of( - TE_PARASITE_ATTACK, - TE_MEDIC_CABLE_ATTACK - ); - - public BeamTEMessage(int style) { - super(style); - } - - public BeamTEMessage(int style, int ownerIndex, float[] origin, float[] destination) { - super(style); - this.ownerIndex = ownerIndex; - this.origin = origin; - this.destination = destination; - } - - public int ownerIndex; - public float[] origin; - public float[] destination; - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writeShort(ownerIndex); - buffer.writePos(origin); - buffer.writePos(destination); - } - - @Override - public void parse(sizebuf_t buffer) { - ownerIndex = buffer.readShort(); - origin = new float[3]; - buffer.readPos(origin); - destination = new float[3]; - buffer.readPos(destination); - } - - @Override - public int getSize() { - return 2 + 2 + 2 * 2 * 3; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "BeamTEMessage{" + - "ownerIndex=" + ownerIndex + - ", origin=" + Arrays.toString(origin) + - ", destination=" + Arrays.toString(destination) + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - BeamTEMessage that = (BeamTEMessage) o; - - if (ownerIndex != that.ownerIndex) return false; - if (!Arrays.equals(origin, that.origin)) return false; - return Arrays.equals(destination, that.destination); - } - - @Override - public int hashCode() { - int result = ownerIndex; - result = 31 * result + Arrays.hashCode(origin); - result = 31 * result + Arrays.hashCode(destination); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointDirectionTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointDirectionTEMessage.java deleted file mode 100644 index fdd1f0e9..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointDirectionTEMessage.java +++ /dev/null @@ -1,91 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.*; - -public class PointDirectionTEMessage extends PointTEMessage { - - public static final Collection SUBTYPES = Set.of( - TE_BLOOD, - TE_GUNSHOT, - TE_SPARKS, - TE_BULLET_SPARKS, - TE_SCREEN_SPARKS, - TE_SHIELD_SPARKS, - TE_SHOTGUN, - TE_BLASTER, - TE_GREENBLOOD, - TE_BLASTER2, - TE_FLECHETTE, - TE_HEATBEAM_SPARKS, - TE_HEATBEAM_STEAM, - TE_MOREBLOOD, - TE_ELECTRIC_SPARKS - ); - - public PointDirectionTEMessage(int style) { - super(style); - } - - public PointDirectionTEMessage(int style, float[] position, float[] direction) { - super(style, position); - this.direction = direction; - } - - public float[] direction; - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writeDir(direction); - } - - @Override - public void parse(sizebuf_t buffer) { - super.parse(buffer); - this.direction = buffer.readDir(); - - } - - @Override - public int getSize() { - return super.getSize() + 1; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "PointDirectionTEMessage{" + - "direction=" + Arrays.toString(direction) + - ", position=" + Arrays.toString(position) + - ", style=" + style + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - if (!super.equals(o)) return false; - - PointDirectionTEMessage that = (PointDirectionTEMessage) o; - - return Arrays.equals(direction, that.direction); - } - - @Override - public int hashCode() { - int result = super.hashCode(); - result = 31 * result + Arrays.hashCode(direction); - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointTEMessage.java deleted file mode 100644 index 4e334ce5..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointTEMessage.java +++ /dev/null @@ -1,108 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.*; - -/** - * TE_EXPLOSION2 - * TE_GRENADE_EXPLOSION - * TE_GRENADE_EXPLOSION_WATER - * 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 - */ -public class PointTEMessage extends TEMessage { - - public static final Collection SUBTYPES = Set.of( - TE_EXPLOSION2, - TE_GRENADE_EXPLOSION, - TE_GRENADE_EXPLOSION_WATER, - 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 - ); - - public PointTEMessage(int style) { - super(style); - } - - public PointTEMessage(int style, float[] position) { - super(style); - this.position = position; - } - - public float[] position; - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writePos(position); - } - - @Override - public void parse(sizebuf_t buffer) { - this.position = new float[3]; - buffer.readPos(position); - } - - @Override - public int getSize() { - return 8; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "PointTEMessage{" + - "position=" + Arrays.toString(position) + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - PointTEMessage that = (PointTEMessage) o; - - return Arrays.equals(position, that.position); - } - - @Override - public int hashCode() { - return Arrays.hashCode(position); - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 5e25e0d2..8fc46178 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -154,23 +154,7 @@ static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_ case svc_packetentities: msg = new PacketEntitiesMessage(); break; - case svc_temp_entity: - int subtype = buffer.readByte(); - if (PointTEMessage.SUBTYPES.contains(subtype)) { - msg = new PointTEMessage(subtype); - } else if (BeamTEMessage.SUBTYPES.contains(subtype)) { - msg = new BeamTEMessage(subtype); - } else if (BeamOffsetTEMessage.SUBTYPES.contains(subtype)) { - msg = new BeamOffsetTEMessage(subtype); - } else if (PointDirectionTEMessage.SUBTYPES.contains(subtype)) { - msg = new PointDirectionTEMessage(subtype); - } else if (TrailTEMessage.SUBTYPES.contains(subtype)) { - msg = new TrailTEMessage(subtype); - } else if (SplashTEMessage.SUBTYPES.contains(subtype)) { - msg = new SplashTEMessage(subtype); - } else { - throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount - 1, "Unexpected temp entity type:" + subtype); - } + } if (msg != null) { diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SplashTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SplashTEMessage.java deleted file mode 100644 index af2b6c6d..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SplashTEMessage.java +++ /dev/null @@ -1,96 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.*; - -public class SplashTEMessage extends TEMessage { - - public static final Collection SUBTYPES = Set.of( - TE_SPLASH, - TE_LASER_SPARKS, - TE_WELDING_SPARKS, - TE_TUNNEL_SPARKS - ); - - public SplashTEMessage(int style) { - super(style); - } - - public SplashTEMessage(int style, int count, float[] position, float[] direction, int param) { - super(style); - this.count = count; - this.position = position; - this.direction = direction; - this.param = param; - } - - public int count; - public float[] position; - public float[] direction; - public int param; - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writeByte((byte) count); - buffer.writePos(position); - buffer.writeDir(direction); - buffer.writeByte((byte) param); - } - - @Override - public void parse(sizebuf_t buffer) { - count = buffer.readByte(); - position = new float[3]; - buffer.readPos(position); - direction = buffer.readDir(); - param = buffer.readByte(); - } - - @Override - public int getSize() { - return 2 + 1 + 6 + 1 + 1 ; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "SplashTEMessage{" + - "count=" + count + - ", position=" + Arrays.toString(position) + - ", direction=" + Arrays.toString(direction) + - ", param=" + param + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - SplashTEMessage that = (SplashTEMessage) o; - - if (count != that.count) return false; - if (param != that.param) return false; - if (!Arrays.equals(position, that.position)) return false; - return Arrays.equals(direction, that.direction); - } - - @Override - public int hashCode() { - int result = count; - result = 31 * result + Arrays.hashCode(position); - result = 31 * result + Arrays.hashCode(direction); - result = 31 * result + param; - return result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java deleted file mode 100644 index 803b4389..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TEMessage.java +++ /dev/null @@ -1,43 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Collection; - -/** - * Temp entity - */ -public abstract class TEMessage extends LegacyServerMessage { - public TEMessage(int style) { - super(ServerMessageType.svc_temp_entity); - validateStyle(style); - this.style = style; - } - - public int style; - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeByte((byte) style); - } - - @Override - public void parse(sizebuf_t buffer) { - this.style = buffer.readByte(); - } - - @Override - public String toString() { - return "TEMessage{" + - "style=" + style + - '}'; - } - - abstract Collection getSupportedStyles(); - - protected final void validateStyle(int style) { - if (!getSupportedStyles().contains(style)) { - throw new IllegalArgumentException("Wrong style for temp entity: " + style + " for class: " + this.getClass().getSimpleName()); - } - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TrailTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/TrailTEMessage.java deleted file mode 100644 index 85803f68..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/TrailTEMessage.java +++ /dev/null @@ -1,81 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; - -import static jake2.qcommon.Defines.*; - -public class TrailTEMessage extends PointTEMessage { - - public static final Collection SUBTYPES = Set.of( - TE_BUBBLETRAIL, - TE_RAILTRAIL, - TE_BLUEHYPERBLASTER, - TE_DEBUGTRAIL, - TE_BFG_LASER - ); - - public TrailTEMessage(int style) { - super(style); - } - - public TrailTEMessage(int style, float[] position, float[] destination) { - super(style, position); - this.destination = destination; - } - - public float[] destination; - - @Override - protected void writeProperties(sizebuf_t buffer) { - super.writeProperties(buffer); - buffer.writePos(destination); - } - - @Override - public void parse(sizebuf_t buffer) { - super.parse(buffer); - this.destination = new float[3]; - buffer.readPos(destination); - } - - @Override - public int getSize() { - return super.getSize() + 6; - } - - @Override - Collection getSupportedStyles() { - return SUBTYPES; - } - - @Override - public String toString() { - return "TrailTEMessage{" + - "position=" + Arrays.toString(position) + - ", style=" + style + - ", destination=" + Arrays.toString(destination) + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - if (!super.equals(o)) return false; - - TrailTEMessage that = (TrailTEMessage) o; - - return Arrays.equals(destination, that.destination); - } - - @Override - public int hashCode() { - int result = super.hashCode(); - result = 31 * result + Arrays.hashCode(destination); - 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 06ab9d4b..4894c440 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 @@ -36,6 +36,7 @@ object ServerMessageCodec { message.source?.let { buffer.writeShort((it.entityIndex shl 3) or it.channel) } message.origin?.let { buffer.writeCoord(it.x); buffer.writeCoord(it.y); buffer.writeCoord(it.z) } } + is TEMessage -> TemporaryEntityCodec.writeProperties(message, buffer) is InventoryMessage -> repeat(MAX_ITEMS) { buffer.writeShort(message[it]) } is DownloadMessage.Chunk -> { buffer.writeShort(message.data.size) @@ -75,6 +76,7 @@ object ServerMessageCodec { is DownloadMessage.Chunk -> 4 + message.data.size is DownloadMessage.Failure -> 4 is FrameHeaderMessage -> 11 + message.areaBits.size + is TEMessage -> TemporaryEntityCodec.size(message) DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 is LegacyServerMessage -> message.size else -> error("Unsupported server message ${message.javaClass.name}") @@ -105,6 +107,7 @@ object ServerMessageCodec { ServerMessageType.svc_muzzleflash -> WeaponSoundMessage(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_sound -> readSound(buffer) + ServerMessageType.svc_temp_entity -> TemporaryEntityCodec.read(buffer) ServerMessageType.svc_inventory -> InventoryMessage(IntArray(MAX_ITEMS) { buffer.readSignedShort() }) ServerMessageType.svc_download -> readDownload(buffer) ServerMessageType.svc_frame -> readFrameHeader(buffer) 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 new file mode 100644 index 00000000..ae615bcf --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityCodec.kt @@ -0,0 +1,59 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.protocol.DirectionCodec +import jake2.qcommon.network.protocol.MessageReadException +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.network.protocol.MessageRejection +import jake2.qcommon.sizebuf_t + +/** Subtype-specific temporary-entity wire layouts. The server codec owns the enclosing opcode. */ +object TemporaryEntityCodec { + fun writeProperties(message: TEMessage, buffer: sizebuf_t) { + buffer.writeByte(message.style.toByte()) + when (message) { + is PointTEMessage -> writePosition(message.position, buffer) + is BeamTEMessage -> { + buffer.writeShort(message.ownerIndex) + writePosition(message.origin, buffer); writePosition(message.destination, buffer) + } + is BeamOffsetTEMessage -> { + buffer.writeShort(message.ownerIndex) + writePosition(message.origin, buffer); writePosition(message.destination, buffer); writePosition(message.offset, buffer) + } + is PointDirectionTEMessage -> { writePosition(message.position, buffer); buffer.writeByte(message.directionIndex.toByte()) } + is TrailTEMessage -> { writePosition(message.position, buffer); writePosition(message.destination, buffer) } + is SplashTEMessage -> { + buffer.writeByte(message.count.toByte()); writePosition(message.position, buffer) + buffer.writeByte(message.directionIndex.toByte()); buffer.writeByte(message.param.toByte()) + } + } + } + + /** Includes the server opcode and subtype byte. */ + fun size(message: TEMessage): Int = when (message) { + is PointTEMessage -> 8 + is BeamTEMessage -> 16 + is BeamOffsetTEMessage -> 22 + is PointDirectionTEMessage -> 9 + is TrailTEMessage -> 14 + is SplashTEMessage -> 11 + } + + fun read(buffer: MessageReader): TEMessage { + val style = buffer.readUnsignedByte() + return when (style) { + 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()) + in PointDirectionTEMessage.SUBTYPES -> PointDirectionTEMessage(style, buffer.readPosition(), DirectionCodec.readIndex(buffer)) + in TrailTEMessage.SUBTYPES -> TrailTEMessage(style, buffer.readPosition(), buffer.readPosition()) + in SplashTEMessage.SUBTYPES -> SplashTEMessage(style, buffer.readUnsignedByte(), buffer.readPosition(), DirectionCodec.readIndex(buffer), buffer.readUnsignedByte()) + else -> throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount - 1, "Unexpected temp entity type: $style") + } + } + + private fun writePosition(position: Vector3f, buffer: sizebuf_t) { + buffer.writeCoord(position.x); buffer.writeCoord(position.y); buffer.writeCoord(position.z) + } +} 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 new file mode 100644 index 00000000..f19a60ef --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/TemporaryEntityMessages.kt @@ -0,0 +1,86 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +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. */ +sealed class TEMessage : ServerMessage(ServerMessageType.svc_temp_entity) { + abstract val style: Int +} + +private fun styles(vararg values: Int): Set = Collections.unmodifiableSet(values.toSet()) + +/** + * Position-only effects: TE_EXPLOSION2, TE_GRENADE_EXPLOSION, TE_GRENADE_EXPLOSION_WATER, + * 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. + */ +data class PointTEMessage(override val style: Int, val position: Vector3f) : TEMessage() { + init { require(style in SUBTYPES) { "Wrong point effect style: $style" } } + constructor(style: Int, position: FloatArray) : this(style, Vector3f(position)) + + companion object { + @JvmField val SUBTYPES = styles(TE_EXPLOSION2, TE_GRENADE_EXPLOSION, TE_GRENADE_EXPLOSION_WATER, + 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_PARASITE_ATTACK and TE_MEDIC_CABLE_ATTACK 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) } +} + +data class BeamOffsetTEMessage(override val style: Int, val ownerIndex: Int, val origin: Vector3f, + val destination: Vector3f, val offset: Vector3f) : TEMessage() { + init { require(style in SUBTYPES) { "Wrong offset beam effect style: $style" } } + constructor(style: Int, ownerIndex: Int, origin: FloatArray, destination: FloatArray, offset: FloatArray) : + this(style, ownerIndex, Vector3f(origin), Vector3f(destination), Vector3f(offset)) + companion object { @JvmField val SUBTYPES = styles(TE_GRAPPLE_CABLE) } +} + +/** Direction is retained as its normal-table index, avoiding repeated lossy quantization. */ +data class PointDirectionTEMessage(override val style: Int, val position: Vector3f, val directionIndex: Int) : TEMessage() { + init { + require(style in SUBTYPES) { "Wrong directional point effect style: $style" } + require(directionIndex in 0 until NUMVERTEXNORMALS) { "Invalid direction index" } + } + val direction: Vector3f get() = DirectionCodec.vector(directionIndex) + constructor(style: Int, position: FloatArray, direction: FloatArray?) : + this(style, Vector3f(position), DirectionCodec.index(direction?.let(::Vector3f))) + + companion object { + @JvmField val SUBTYPES = styles(TE_BLOOD, TE_GUNSHOT, TE_SPARKS, TE_BULLET_SPARKS, + TE_SCREEN_SPARKS, TE_SHIELD_SPARKS, TE_SHOTGUN, TE_BLASTER, TE_GREENBLOOD, + TE_BLASTER2, TE_FLECHETTE, TE_HEATBEAM_SPARKS, TE_HEATBEAM_STEAM, + TE_MOREBLOOD, TE_ELECTRIC_SPARKS) + } +} + +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) } +} + +data class SplashTEMessage(override val style: Int, val count: Int, val position: Vector3f, + val directionIndex: Int, val param: Int) : TEMessage() { + init { + require(style in SUBTYPES) { "Wrong splash effect style: $style" } + require(directionIndex in 0 until NUMVERTEXNORMALS) { "Invalid direction index" } + require(count in 0..255 && param in 0..255) { "Splash fields exceed unsigned byte range" } + } + val direction: Vector3f get() = DirectionCodec.vector(directionIndex) + constructor(style: Int, count: Int, position: FloatArray, direction: FloatArray?, param: Int) : + 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) } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt new file mode 100644 index 00000000..62b8dc0f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt @@ -0,0 +1,29 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Globals +import jake2.qcommon.math.Vector3f + +/** Immutable snapshot of the protocol's fixed normal table and its legacy nearest-dot-product selection rule. */ +object DirectionCodec { + private val normals = Globals.bytedirs.map(::Vector3f) + + fun vector(index: Int): Vector3f = normals[index] + + fun index(direction: Vector3f?): Int { + if (direction == null) return 0 + var bestDot = 0f + var bestIndex = 0 + for ((index, normal) in normals.withIndex()) { + val dot = direction.x * normal.x + direction.y * normal.y + direction.z * normal.z + if (dot > bestDot) { bestDot = dot; bestIndex = index } + } + return bestIndex + } + + fun readIndex(buffer: MessageReader): Int { + val index = buffer.readUnsignedByte() + if (index !in normals.indices) + throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount - 1, "Invalid direction index $index") + return index + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt new file mode 100644 index 00000000..dc3a4621 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/TemporaryEntityCodecTest.kt @@ -0,0 +1,138 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.Globals +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class TemporaryEntityCodecTest { + private data class Family(val styles: Set, val payload: ByteArray, val value: (Int) -> TEMessage) + private val position = Vector3f(1f, 2f, 3f) + private val end = Vector3f(4f, 5f, 6f) + private val offset = Vector3f(7f, 8f, 9f) + private val pointBytes = byteArrayOf(8, 0, 16, 0, 24, 0) + private val endBytes = byteArrayOf(32, 0, 40, 0, 48, 0) + private val offsetBytes = byteArrayOf(56, 0, 64, 0, 72, 0) + + private fun families() = listOf( + Family(PointTEMessage.SUBTYPES, pointBytes) { PointTEMessage(it, position) }, + Family(BeamTEMessage.SUBTYPES, byteArrayOf(-1, -1) + pointBytes + endBytes) { BeamTEMessage(it, -1, position, end) }, + Family(BeamOffsetTEMessage.SUBTYPES, byteArrayOf(0, -128) + pointBytes + endBytes + offsetBytes) { + BeamOffsetTEMessage(it, -32768, position, end, offset) + }, + 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) }, + ) + + private fun write(message: TEMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray): TEMessage { + val reader = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ServerMessageCodec.read(reader) as TEMessage + assertEquals(bytes.size, reader.readcount) + reader.data.fill(0) + return message + } + + private fun body(bytes: ByteArray): MessageDecodeResult { + val wire = PacketCodec.encodeSequenced(1, 0, null, PacketBytes.copyOf(bytes)) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + return MessageBodyCodec.decodeServer(envelope) + } + + @Test + fun `all supported subtypes retain their exact field order widths and signed owners`() { + assertEquals(45, families().flatMap { it.styles }.toSet().size) + 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)) + assertEquals(message.hashCode(), read(expected).hashCode()) + for (length in 1 until expected.size) { + val result = body(expected.copyOf(length)) as MessageDecodeResult.Rejected + assertEquals(MessageRejection.TRUNCATED, result.reason, "Style $style prefix $length") + } + } + } + + @Test + fun `normal indices survive decoding without requantization and invalid indices reject atomically`() { + for (index in 0 until NUMVERTEXNORMALS) { + val values = listOf(PointDirectionTEMessage(TE_GUNSHOT, position, index), SplashTEMessage(TE_SPLASH, 1, position, index, 7)) + for (message in values) assertArrayEquals(write(message), write(read(write(message)))) + assertEquals(Vector3f(Globals.bytedirs[index]), DirectionCodec.vector(index)) + } + for (index in NUMVERTEXNORMALS..255) { + for (bytes in listOf( + byteArrayOf(3, TE_GUNSHOT.toByte()) + pointBytes + byteArrayOf(index.toByte()), + byteArrayOf(3, TE_SPLASH.toByte(), 1) + pointBytes + byteArrayOf(index.toByte(), 7), + )) assertEquals(MessageRejection.INVALID_VALUE, (body(byteArrayOf(7) + bytes) as MessageDecodeResult.Rejected).reason) + } + val known = families().flatMap { it.styles }.toSet() + for (style in 0..255) if (style !in known) { + assertEquals(MessageRejection.INVALID_VALUE, + (body(byteArrayOf(7, 3, style.toByte())) as MessageDecodeResult.Rejected).reason) + } + } + + @Test + fun `game direction quantization matches the legacy encoder including null zero and arbitrary vectors`() { + val random = Random(173) + val directions = listOf(null, floatArrayOf(0f, 0f, 0f), floatArrayOf(1f, 0f, 0f), floatArrayOf(-1f, 1f, 0f)) + + List(1024) { FloatArray(3) { random.nextFloat() * 20f - 10f } } + for (direction in directions) { + val reference = sizebuf_t(1).apply { writeDir(direction) } + val value = PointDirectionTEMessage(TE_GUNSHOT, floatArrayOf(0f, 0f, 0f), direction) + assertEquals(reference.data[0].toInt() and 255, value.directionIndex) + } + } + + @Test + fun `queued temporary effects own every source vector and preserve unsigned game byte wrapping`() { + val source = floatArrayOf(1f, 2f, 3f) + val destination = floatArrayOf(4f, 5f, 6f) + val offset = floatArrayOf(7f, 8f, 9f) + val direction = Globals.bytedirs[0].clone() + val values = listOf( + PointTEMessage(TE_EXPLOSION1, source), BeamTEMessage(TE_PARASITE_ATTACK, 2, source, destination), + BeamOffsetTEMessage(TE_GRAPPLE_CABLE, 3, source, destination, offset), + PointDirectionTEMessage(TE_GUNSHOT, source, direction), TrailTEMessage(TE_RAILTRAIL, source, destination), + SplashTEMessage(TE_SPLASH, 511, source, direction, -1), + ) + val before = values.map { write(it) to it.hashCode() } + listOf(source, destination, offset, direction).forEach { it.fill(99f) } + DirectionCodec.vector(0).toArray().fill(99f) + for ((message, original) in values.zip(before)) { + assertArrayEquals(original.first, write(message)) + assertEquals(original.second, message.hashCode()) + assertEquals(message, read(original.first)) + } + assertEquals(255, (values.last() as SplashTEMessage).count) + assertEquals(255, (values.last() as SplashTEMessage).param) + } + + @Test + fun `effect subtype participates in equality and constructors reject mismatched layouts`() { + for (family in families().filter { it.styles.size > 1 }) { + val styles = family.styles.toList() + val first = family.value(styles[0]) + val second = family.value(styles[1]) + assertNotEquals(first, second) + assertEquals(2, setOf(first, second).size) + assertThrows(IllegalArgumentException::class.java) { family.value(255) } + } + assertThrows(IllegalArgumentException::class.java) { PointDirectionTEMessage(TE_GUNSHOT, position, 255) } + } +} From 00eaf515ad1897e6f928b34041a888931e49faa2 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 23:05:35 +0100 Subject: [PATCH 44/57] Snapshot movement commands and encode CRCs with packet context --- .../jake2/server/HostClientAccountingTest.kt | 28 ++- .../org/demoth/cake/input/InputManager.kt | 5 +- .../input/InputManagerDeltaCompressionTest.kt | 19 +- .../cake/stages/InputManagerAngleSyncTest.kt | 14 +- .../InputManagerBindingsIntegrationTest.kt | 14 +- .../cake/stages/InputManagerTimingTest.kt | 8 +- .../network/messages/NetworkMessage.java | 5 + .../messages/client/ClientMessage.java | 5 + .../network/messages/client/MoveMessage.java | 232 ------------------ .../java/jake2/qcommon/network/netchan_t.java | 11 +- .../messages/client/ClientMessageCodec.kt | 15 +- .../network/messages/client/MoveMessage.kt | 76 ++++++ .../network/messages/client/MovementCodec.kt | 83 +++++++ .../messages/NetworkMessageSizeTest.java | 46 ++-- .../protocol/ClientMessageCodecTest.kt | 8 +- .../network/protocol/MessageBodyCodecTest.kt | 12 +- .../network/protocol/MovementCodecTest.kt | 90 +++++++ .../network/protocol/PacketCodecTest.kt | 10 +- .../network/protocol/ReliableChannelTest.kt | 6 +- .../network/runtime/OwnedChannelTest.kt | 20 ++ .../main/kotlin/jake2/server/ServerHost.kt | 8 +- 21 files changed, 411 insertions(+), 304 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MoveMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MovementCodec.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 237260aa..35ced0c1 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -7,6 +7,8 @@ import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import jake2.qcommon.network.* import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.network.messages.client.UserCommand +import jake2.qcommon.network.messages.client.UserInfoMessage import jake2.qcommon.network.messages.server.DownloadMessage import jake2.qcommon.vfs.* import org.junit.jupiter.api.* @@ -15,6 +17,28 @@ import org.junit.jupiter.api.io.TempDir import java.nio.file.Path class HostClientAccountingTest { + @Test + fun `checksum rejection skips trailing commands and callbacks cannot mutate command snapshots`() { + val client = bind(0, first) + client.state = ClientStates.CS_SPAWNED + client.commandMsec = 1000 + var calls = 0 + first.gameExports = object : GameExports by first.gameExports { + override fun ClientThink(ent: ServerEntity, cmd: usercmd_t) { calls++; cmd.clear() } + } + val command = UserCommand(msec = 16, forwardmove = 120) + val movement = MoveMessage(-1, UserCommand(), UserCommand(), command) + host.handleClientMessages(client, listOf(movement.copy(checksumValid = false), UserInfoMessage("\\name\\must_not_apply"))) + assertEquals(0, calls) + assertEquals("", client.userinfo) + assertEquals(ClientStates.CS_SPAWNED, client.state) + host.handleClientMessages(client, listOf(movement)) + assertEquals(1, calls) + assertEquals(120, movement.newCmd.forwardmove) + assertEquals(120, client.lastcmd.forwardmove.toInt()) + assertEquals(16, client.lastcmd.msec.toInt() and 255) + } + @TempDir lateinit var root: Path private lateinit var previousRoot: Path private var previousState: ServerStates? = null @@ -253,7 +277,7 @@ class HostClientAccountingTest { override fun G_RunFrame() { frames++ } override fun ClientThink(ent: ServerEntity, cmd: usercmd_t) { moves++ } } - val move = MoveMessage(false, -1, usercmd_t(), usercmd_t(), usercmd_t(), 0).apply { valid = true } + val move = MoveMessage.fromInput(false, -1, usercmd_t(), usercmd_t(), usercmd_t()) cvars.find("paused").forceSet(true) // Direct console request, bypassing menu filtering. first.runFrame() host.handleClientMessages(client, listOf(move)) @@ -280,7 +304,7 @@ class HostClientAccountingTest { client.state = ClientStates.CS_CONNECTED host.update(100) first.frameNum = 0 - val move = MoveMessage(false, snapshot, usercmd_t(), usercmd_t(), usercmd_t(), 0) + val move = MoveMessage.fromInput(false, snapshot, usercmd_t(), usercmd_t(), usercmd_t()) host.handleClientMessages(client, listOf(move)) client.state = ClientStates.CS_SPAWNED host.updateClientPings() 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 aefba641..ae26f02c 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 @@ -156,13 +156,12 @@ class InputManager( val oldestCmd = getHistoricalCommand(outgoingSequence - 2) // deliver the message - return MoveMessage( + return MoveMessage.fromInput( !currentFrame.valid, currentFrame.serverframe, oldestCmd, oldCmd, - userCommands[cmdIndex], - outgoingSequence + userCommands[cmdIndex] ) } 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 7e4faf91..8e65c965 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 @@ -3,6 +3,7 @@ package org.demoth.cake.input import jake2.qcommon.Defines.PITCH import jake2.qcommon.Defines.PM_NORMAL import jake2.qcommon.Defines.YAW +import jake2.qcommon.Defines.CMD_BACKUP import jake2.qcommon.network.messages.client.ClientMessage import jake2.qcommon.network.messages.client.MoveMessage import jake2.qcommon.sizebuf_t @@ -10,11 +11,27 @@ import jake2.qcommon.util.Math3D import org.demoth.cake.CakeCvars import org.demoth.cake.ClientFrame import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test /** Checks move-packet delta compression; input definitions are the only cvar prerequisites. */ class InputManagerDeltaCompressionTest { + @Test + fun queuedMovementSurvivesInputHistorySlotReuse() { + val input = InputManager(nowNanosProvider = { 1_000_000L }) + val current = frame(serverframe = 77, valid = true) + input.updateLocalInput(0f, current) + val queued = input.buildMoveMessage(5, current, 16) + val before = sizebuf_t(queued.size).apply { queued.writeTo(this, 5) }.data + val hash = queued.hashCode() + input.buildMoveMessage(5 + CMD_BACKUP, current, 99) + assertEquals(16, queued.newCmd.msec) + assertEquals(hash, queued.hashCode()) + assertArrayEquals(before, sizebuf_t(queued.size).apply { queued.writeTo(this, 5) }.data) + } + @BeforeEach fun registerInputCvars() { CakeCvars.registerInput() @@ -40,7 +57,7 @@ class InputManagerDeltaCompressionTest { private fun roundTrip(message: MoveMessage, incomingSequence: Int): MoveMessage { val buffer = sizebuf_t() buffer.init(ByteArray(256), 256) - message.writeTo(buffer) + message.writeTo(buffer, incomingSequence) return ClientMessage.parseFromBuffer(buffer, incomingSequence) as MoveMessage } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt index 0b6e6630..0916ec25 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt @@ -35,8 +35,8 @@ class InputManagerAngleSyncTest { assertEquals(10f, input.initialYaw!!, angleEpsilon) assertEquals(5f, input.localPitch, angleEpsilon) assertEquals(2f, input.initialPitch!!, angleEpsilon) - assertEquals(20f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW].toInt())), cmdAngleEpsilon) - assertEquals(3f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[PITCH].toInt())), cmdAngleEpsilon) + assertEquals(20f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW])), cmdAngleEpsilon) + assertEquals(3f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[PITCH])), cmdAngleEpsilon) } @Test @@ -54,7 +54,7 @@ class InputManagerAngleSyncTest { assertEquals(65f, input.localYaw, angleEpsilon) assertEquals(25f, input.initialYaw!!, angleEpsilon) - assertEquals(40f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW].toInt())), cmdAngleEpsilon) + assertEquals(40f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW])), cmdAngleEpsilon) } @Test @@ -68,7 +68,7 @@ class InputManagerAngleSyncTest { assertEquals(90f, input.localYaw, angleEpsilon) assertEquals(30f, input.initialYaw!!, angleEpsilon) - assertEquals(60f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW].toInt())), cmdAngleEpsilon) + assertEquals(60f, wrapSignedAngle(Math3D.SHORT2ANGLE(msg.newCmd.angles[YAW])), cmdAngleEpsilon) } @Test @@ -85,9 +85,9 @@ class InputManagerAngleSyncTest { val msg = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame) - assertEquals(0, msg.newCmd.forwardmove.toInt()) - assertEquals(0, msg.newCmd.sidemove.toInt()) - assertEquals(0, msg.newCmd.upmove.toInt()) + assertEquals(0, msg.newCmd.forwardmove) + assertEquals(0, msg.newCmd.sidemove) + assertEquals(0, msg.newCmd.upmove) } private fun frame( diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerBindingsIntegrationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerBindingsIntegrationTest.kt index 1c106550..ba14e96c 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerBindingsIntegrationTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerBindingsIntegrationTest.kt @@ -33,7 +33,7 @@ class InputManagerBindingsIntegrationTest { input.keyDown(Input.Keys.X) val msg = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame()) - assertEquals(200, msg.newCmd.forwardmove.toInt()) + assertEquals(200, msg.newCmd.forwardmove) } @Test @@ -78,11 +78,11 @@ class InputManagerBindingsIntegrationTest { input.keyUp(Input.Keys.Q) val stillForward = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame) - assertEquals(200, stillForward.newCmd.forwardmove.toInt()) + assertEquals(200, stillForward.newCmd.forwardmove) input.keyUp(Input.Keys.E) val released = input.gatherInput(outgoingSequence = 1, deltaTime = 0f, currentFrame = frame) - assertEquals(0, released.newCmd.forwardmove.toInt()) + assertEquals(0, released.newCmd.forwardmove) } @Test @@ -95,8 +95,8 @@ class InputManagerBindingsIntegrationTest { input.keyDown(Input.Keys.U) val msg = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame()) - assertTrue((msg.newCmd.buttons.toInt() and BUTTON_USE) != 0) - assertTrue((msg.newCmd.buttons.toInt() and BUTTON_ANY) != 0) + assertTrue((msg.newCmd.buttons and BUTTON_USE) != 0) + assertTrue((msg.newCmd.buttons and BUTTON_ANY) != 0) } @Test @@ -109,7 +109,7 @@ class InputManagerBindingsIntegrationTest { input.keyDown(Input.Keys.X) val msg = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame()) - assertTrue((msg.newCmd.buttons.toInt() and BUTTON_ANY) != 0) + assertTrue((msg.newCmd.buttons and BUTTON_ANY) != 0) } @Test @@ -125,7 +125,7 @@ class InputManagerBindingsIntegrationTest { input.clearInputState() val msg = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame) - assertEquals(0, msg.newCmd.forwardmove.toInt()) + assertEquals(0, msg.newCmd.forwardmove) } private fun frame(): ClientFrame { diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerTimingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerTimingTest.kt index 2a0c12cb..c126c9cf 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerTimingTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerTimingTest.kt @@ -28,18 +28,18 @@ class InputManagerTimingTest { val frame = frame() val first = input.gatherInput(outgoingSequence = 0, deltaTime = 0f, currentFrame = frame) - assertEquals(16, first.newCmd.msec.toInt() and 0xFF) + assertEquals(16, first.newCmd.msec) clock.advanceMs(7) val second = input.gatherInput(outgoingSequence = 1, deltaTime = 0f, currentFrame = frame) - assertEquals(7, second.newCmd.msec.toInt() and 0xFF) + assertEquals(7, second.newCmd.msec) val third = input.gatherInput(outgoingSequence = 2, deltaTime = 0f, currentFrame = frame) - assertEquals(1, third.newCmd.msec.toInt() and 0xFF) + assertEquals(1, third.newCmd.msec) clock.advanceMs(500) val fourth = input.gatherInput(outgoingSequence = 3, deltaTime = 0f, currentFrame = frame) - assertEquals(100, fourth.newCmd.msec.toInt() and 0xFF) + assertEquals(100, fourth.newCmd.msec) } @Test diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java index a4feba06..b4dbff9f 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java @@ -6,6 +6,11 @@ public interface NetworkMessage { void writeTo(sizebuf_t buffer); + /** Encode using the packet's reserved sequence; sequence-independent messages use their ordinary codec. */ + default void writeTo(sizebuf_t buffer, int sequence) { + writeTo(buffer); + } + /** * size of the message in bytes */ diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java index 18243b25..a63b820e 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessage.java @@ -15,6 +15,11 @@ public final void writeTo(sizebuf_t buffer) { ClientMessageCodec.write(this, buffer); } + @Override + public final void writeTo(sizebuf_t buffer, int sequence) { + ClientMessageCodec.write(this, buffer, sequence); + } + /** Size of the message in bytes. */ public final int getSize() { return ClientMessageCodec.size(this); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java deleted file mode 100644 index 6bed7449..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java +++ /dev/null @@ -1,232 +0,0 @@ -package jake2.qcommon.network.messages.client; - -import jake2.qcommon.Defines; -import jake2.qcommon.sizebuf_t; -import jake2.qcommon.usercmd_t; - -public class MoveMessage extends ClientMessage { - private int checksumIndex; - // When set, write an invalid lastReceivedFrame marker so the server sends the next world - // snapshot as a full frame instead of delta-compressing against a client frame we could not - // reconstruct locally. - private boolean noCompress; - public int lastReceivedFrame; - public usercmd_t oldestCmd; - public usercmd_t oldCmd; - public usercmd_t newCmd; - public final int currentSequence; - public boolean valid; - - private final usercmd_t nullCmd = new usercmd_t(); - - protected MoveMessage(int currentSequence) { - super(ClientMessageType.CLC_MOVE); - this.currentSequence = currentSequence; - } - - public MoveMessage(boolean noCompress, int lastReceivedFrame, usercmd_t oldestCmd, usercmd_t oldCmd, usercmd_t newCmd, int currentSequence) { - this(currentSequence); - this.noCompress = noCompress; - this.lastReceivedFrame = lastReceivedFrame; - this.oldestCmd = oldestCmd; - this.oldCmd = oldCmd; - this.newCmd = newCmd; - } - - // Codec delegation while movement state/checksum handling is migrated separately. - void writeMovementProperties(sizebuf_t buffer) { - checksumIndex = buffer.cursize; - // later we update with right value - buffer.writeByte((byte) 0); - if (noCompress) { - buffer.writeInt(0); - } else { - buffer.writeInt(lastReceivedFrame); - } - - // send this and the previous cmds in the message, so - // if the last packet was dropped, it can be recovered - writeDeltaUserCommand(buffer, nullCmd, oldestCmd); - writeDeltaUserCommand(buffer, oldestCmd, oldCmd); - writeDeltaUserCommand(buffer, oldCmd, newCmd); - buffer.data[checksumIndex] = CRC.BlockSequenceCRCByte(buffer.data, - checksumIndex + 1, - buffer.cursize - checksumIndex - 1, - currentSequence - ); - } - - public void parse(sizebuf_t buffer) { - checksumIndex = buffer.readcount; - int checksum = buffer.readByte(); - lastReceivedFrame = buffer.readInt(); - this.oldestCmd = new usercmd_t(); - this.oldCmd = new usercmd_t(); - this.newCmd = new usercmd_t(); - - readDeltaUserCommand(buffer, new usercmd_t(), oldestCmd); - readDeltaUserCommand(buffer, oldestCmd, oldCmd); - readDeltaUserCommand(buffer, oldCmd, newCmd); - - int calculatedChecksum = CRC.BlockSequenceCRCByte( - buffer.data, - checksumIndex + 1, - buffer.readcount - checksumIndex - 1, - currentSequence); - - valid = (calculatedChecksum & 0xff) == checksum; - } - - private static void writeDeltaUserCommand(sizebuf_t buffer, usercmd_t from, usercmd_t cmd) { - - // - // send the movement message - // - int deltaFlags = getDeltaFlags(from, cmd); - - buffer.writeByte((byte) deltaFlags); - - if ((deltaFlags & Defines.CM_ANGLE1) != 0) - buffer.writeShort(cmd.angles[0]); - if ((deltaFlags & Defines.CM_ANGLE2) != 0) - buffer.writeShort(cmd.angles[1]); - if ((deltaFlags & Defines.CM_ANGLE3) != 0) - buffer.writeShort(cmd.angles[2]); - - if ((deltaFlags & Defines.CM_FORWARD) != 0) - buffer.writeShort(cmd.forwardmove); - if ((deltaFlags & Defines.CM_SIDE) != 0) - buffer.writeShort(cmd.sidemove); - if ((deltaFlags & Defines.CM_UP) != 0) - buffer.writeShort(cmd.upmove); - - if ((deltaFlags & Defines.CM_BUTTONS) != 0) - buffer.writeByte(cmd.buttons); - if ((deltaFlags & Defines.CM_IMPULSE) != 0) - buffer.writeByte(cmd.impulse); - - buffer.writeByte(cmd.msec); - buffer.writeByte(cmd.lightlevel); - } - - private static int getDeltaFlags(usercmd_t from, usercmd_t cmd) { - int deltaFlags = 0; - if (cmd.angles[0] != from.angles[0]) - deltaFlags |= Defines.CM_ANGLE1; - if (cmd.angles[1] != from.angles[1]) - deltaFlags |= Defines.CM_ANGLE2; - if (cmd.angles[2] != from.angles[2]) - deltaFlags |= Defines.CM_ANGLE3; - if (cmd.forwardmove != from.forwardmove) - deltaFlags |= Defines.CM_FORWARD; - if (cmd.sidemove != from.sidemove) - deltaFlags |= Defines.CM_SIDE; - if (cmd.upmove != from.upmove) - deltaFlags |= Defines.CM_UP; - if (cmd.buttons != from.buttons) - deltaFlags |= Defines.CM_BUTTONS; - if (cmd.impulse != from.impulse) - deltaFlags |= Defines.CM_IMPULSE; - return deltaFlags; - } - - private static void readDeltaUserCommand(sizebuf_t buffer, usercmd_t from, usercmd_t move) { - move.set(from); - int deltaFlags = buffer.readByte(); - - // read current angles - if ((deltaFlags & Defines.CM_ANGLE1) != 0) - move.angles[0] = buffer.readShort(); - if ((deltaFlags & Defines.CM_ANGLE2) != 0) - move.angles[1] = buffer.readShort(); - if ((deltaFlags & Defines.CM_ANGLE3) != 0) - move.angles[2] = buffer.readShort(); - - // read movement - if ((deltaFlags & Defines.CM_FORWARD) != 0) - move.forwardmove = buffer.readShort(); - if ((deltaFlags & Defines.CM_SIDE) != 0) - move.sidemove = buffer.readShort(); - if ((deltaFlags & Defines.CM_UP) != 0) - move.upmove = buffer.readShort(); - - // read buttons - if ((deltaFlags & Defines.CM_BUTTONS) != 0) - move.buttons = (byte) buffer.readByte(); - - if ((deltaFlags & Defines.CM_IMPULSE) != 0) - move.impulse = (byte) buffer.readByte(); - - // read time to run command - move.msec = (byte) buffer.readByte(); - - // read the light level - move.lightlevel = (byte) buffer.readByte(); - - } - - @Override - public String toString() { - return "MoveMessage{" + - "lastReceivedFrame=" + lastReceivedFrame + - ", oldestCmd=" + oldestCmd + - ", oldCmd=" + oldCmd + - ", newCmd=" + newCmd + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - MoveMessage that = (MoveMessage) o; - - if (lastReceivedFrame != that.lastReceivedFrame) return false; - if (oldestCmd != null ? !oldestCmd.equals(that.oldestCmd) : that.oldestCmd != null) return false; - if (oldCmd != null ? !oldCmd.equals(that.oldCmd) : that.oldCmd != null) return false; - return newCmd != null ? newCmd.equals(that.newCmd) : that.newCmd == null; - } - - @Override - public int hashCode() { - int result = lastReceivedFrame; - result = 31 * result + (oldestCmd != null ? oldestCmd.hashCode() : 0); - result = 31 * result + (oldCmd != null ? oldCmd.hashCode() : 0); - result = 31 * result + (newCmd != null ? newCmd.hashCode() : 0); - return result; - } - - int getMovementSize() { - int result = 6; - int deltaFlagsOldest = getDeltaFlags(nullCmd, oldestCmd); - int deltaFlagsOld = getDeltaFlags(oldestCmd, oldCmd); - int deltaFlagsNew = getDeltaFlags(oldCmd, newCmd); - return result + getDeltaSize(deltaFlagsOldest) + getDeltaSize(deltaFlagsOld) + getDeltaSize(deltaFlagsNew); - } - - private int getDeltaSize(int deltaFlags) { - int result = 1; - - if ((deltaFlags & Defines.CM_ANGLE1) != 0) - result += 2; - if ((deltaFlags & Defines.CM_ANGLE2) != 0) - result += 2; - if ((deltaFlags & Defines.CM_ANGLE3) != 0) - result += 2; - - if ((deltaFlags & Defines.CM_FORWARD) != 0) - result += 2; - if ((deltaFlags & Defines.CM_SIDE) != 0) - result += 2; - if ((deltaFlags & Defines.CM_UP) != 0) - result += 2; - - if ((deltaFlags & Defines.CM_BUTTONS) != 0) - result += 1; - if ((deltaFlags & Defines.CM_IMPULSE) != 0) - result += 1; - - return 2 + result; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java index 580931ef..0bea187c 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java @@ -109,14 +109,14 @@ public boolean transmit(Collection unreliable) { boolean sent = false; try { if (channel.getCanSendReliable() && !reliablePending.isEmpty()) { - PacketBytes reliable = encodeBatch(reliablePending, Defines.MAX_MSGLEN - 16); + PacketBytes reliable = encodeBatch(reliablePending, Defines.MAX_MSGLEN - 16, null); if (channel.enqueueReliable(reliable) != ReliableQueueResult.QUEUED) { fatal_error = true; return false; } staged = true; } - PrepareSendResult result = channel.prepareSend((sequence, capacity) -> encodeBatch(unreliable, capacity)); + PrepareSendResult result = channel.prepareSend((sequence, capacity) -> encodeBatch(unreliable, capacity, sequence)); if (result instanceof PrepareSendResult.SequenceExhausted) { fatal_error = true; return false; @@ -155,11 +155,14 @@ private SendResult send(PacketBytes bytes) { } } - private static PacketBytes encodeBatch(Collection messages, int capacity) { + private static PacketBytes encodeBatch(Collection messages, int capacity, Integer sequence) { if (messages == null || messages.isEmpty()) return PacketBytes.copyOf(new byte[0]); if (messages.stream().mapToLong(NetworkMessage::getSize).sum() > capacity) return null; sizebuf_t buffer = new sizebuf_t(capacity); - messages.forEach(message -> message.writeTo(buffer)); + messages.forEach(message -> { + if (sequence == null) message.writeTo(buffer); + else message.writeTo(buffer, sequence); + }); return PacketBytes.copyOf(buffer.data, buffer.cursize); } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt index 2c0fd7bd..78c1d15b 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt @@ -5,16 +5,23 @@ import jake2.qcommon.network.protocol.MessageReader import jake2.qcommon.network.protocol.MessageRejection import jake2.qcommon.sizebuf_t -/** Client wire operations; values have no mutable parsing state. Movement retains its existing CRC/delta codec. */ +/** Client wire operations; movement checksums use an explicit packet sequence rather than message-owned state. */ object ClientMessageCodec { @JvmStatic fun write(message: ClientMessage, buffer: sizebuf_t) { + require(message !is MoveMessage) { "Movement encoding requires a packet sequence" } + write(message, buffer, 0) + } + + @JvmStatic + fun write(message: ClientMessage, buffer: sizebuf_t, sequence: Int) { + if (message is MoveMessage) require(sequence >= 0) { "Movement sequence must be non-negative" } buffer.writeByte(message.type.value) when (message) { is StringCmdMessage -> buffer.writeString(message.command) is UserInfoMessage -> buffer.writeString(message.userInfo) NoopMessage, EndOfClientPacketMessage -> Unit - is MoveMessage -> message.writeMovementProperties(buffer) + is MoveMessage -> MovementCodec.writeProperties(message, buffer, sequence) else -> error("Unsupported client message ${message.javaClass.name}") } } @@ -25,7 +32,7 @@ object ClientMessageCodec { is StringCmdMessage -> 2 + message.command.length is UserInfoMessage -> 2 + message.userInfo.length NoopMessage, EndOfClientPacketMessage -> 1 - is MoveMessage -> message.movementSize + is MoveMessage -> MovementCodec.size(message) else -> error("Unsupported client message ${message.javaClass.name}") } @@ -44,7 +51,7 @@ object ClientMessageCodec { ClientMessageType.CLC_NOP -> NoopMessage ClientMessageType.CLC_USERINFO -> UserInfoMessage(buffer.readString()) ClientMessageType.CLC_STRINGCMD -> StringCmdMessage(buffer.readString()) - ClientMessageType.CLC_MOVE -> MoveMessage(incomingSequence).apply { parse(buffer) } + ClientMessageType.CLC_MOVE -> MovementCodec.read(buffer, incomingSequence) null -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Unknown client opcode $opcode") } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MoveMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MoveMessage.kt new file mode 100644 index 00000000..909cad79 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MoveMessage.kt @@ -0,0 +1,76 @@ +package jake2.qcommon.network.messages.client + +import jake2.qcommon.usercmd_t + +/** Signed 16-bit command angles, retained as immutable components rather than a shared engine array. */ +data class CommandAngles(val pitch: Int = 0, val yaw: Int = 0, val roll: Int = 0) { + init { + require(pitch in Short.MIN_VALUE..Short.MAX_VALUE && yaw in Short.MIN_VALUE..Short.MAX_VALUE && roll in Short.MIN_VALUE..Short.MAX_VALUE) + } + operator fun get(index: Int): Int = when (index) { + 0 -> pitch + 1 -> yaw + 2 -> roll + else -> throw IndexOutOfBoundsException("Command angle $index") + } +} + +/** Owned command state. Byte fields are unsigned Ints; movement and angle fields are signed shorts. */ +data class UserCommand( + val msec: Int = 0, + val buttons: Int = 0, + val angles: CommandAngles = CommandAngles(), + val forwardmove: Int = 0, + val sidemove: Int = 0, + val upmove: Int = 0, + val impulse: Int = 0, + /** Light level the player is standing on. */ + val lightlevel: Int = 0, +) { + init { + require(msec in 0..255 && buttons in 0..255 && impulse in 0..255 && lightlevel in 0..255) + require(forwardmove in Short.MIN_VALUE..Short.MAX_VALUE && sidemove in Short.MIN_VALUE..Short.MAX_VALUE && upmove in Short.MIN_VALUE..Short.MAX_VALUE) + } + + /** Game callbacks receive their own mutable command, never the network snapshot's storage. */ + fun toEngineCommand(): usercmd_t = usercmd_t().also(::copyTo) + + fun copyTo(target: usercmd_t) { + target.msec = msec.toByte(); target.buttons = buttons.toByte() + target.angles[0] = angles.pitch.toShort(); target.angles[1] = angles.yaw.toShort(); target.angles[2] = angles.roll.toShort() + target.forwardmove = forwardmove.toShort(); target.sidemove = sidemove.toShort(); target.upmove = upmove.toShort() + target.impulse = impulse.toByte(); target.lightlevel = lightlevel.toByte() + } + + companion object { + @JvmStatic fun fromEngine(command: usercmd_t): UserCommand = UserCommand( + command.msec.toInt() and 255, command.buttons.toInt() and 255, + CommandAngles(command.angles[0].toInt(), command.angles[1].toInt(), command.angles[2].toInt()), + command.forwardmove.toInt(), command.sidemove.toInt(), command.upmove.toInt(), + command.impulse.toInt() and 255, command.lightlevel.toInt() and 255, + ) + } +} + +/** + * Send this command and the previous two so a dropped packet can be recovered. + * Checksum validity is immutable decode metadata; encoding recalculates the checksum using its explicit sequence context. + */ +data class MoveMessage( + val lastReceivedFrame: Int, + val oldestCmd: UserCommand, + val oldCmd: UserCommand, + val newCmd: UserCommand, + val checksumValid: Boolean = true, +) : ClientMessage(ClientMessageType.CLC_MOVE) { + companion object { + /** + * When noCompress is set, send an invalid lastReceivedFrame marker so the server sends the next world + * snapshot as a full frame instead of delta-compressing against a frame we could not reconstruct locally. + */ + @JvmStatic + fun fromInput(noCompress: Boolean, lastReceivedFrame: Int, oldestCmd: usercmd_t, oldCmd: usercmd_t, newCmd: usercmd_t): MoveMessage = + MoveMessage(if (noCompress) 0 else lastReceivedFrame, + UserCommand.fromEngine(oldestCmd), UserCommand.fromEngine(oldCmd), UserCommand.fromEngine(newCmd)) + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MovementCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MovementCodec.kt new file mode 100644 index 00000000..5dfde1c7 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MovementCodec.kt @@ -0,0 +1,83 @@ +package jake2.qcommon.network.messages.client + +import jake2.qcommon.Defines.* +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.sizebuf_t + +/** Three command deltas and their sequence-dependent CRC. No mutable checksum scratch state is retained. */ +object MovementCodec { + private val zero = UserCommand() + + fun writeProperties(message: MoveMessage, buffer: sizebuf_t, sequence: Int) { + require(sequence >= 0) { "Movement sequence must be non-negative" } + val checksumIndex = buffer.cursize + // Reserve a byte and fill it after the complete movement payload is written. + buffer.writeByte(0) + buffer.writeInt(message.lastReceivedFrame) + writeDelta(zero, message.oldestCmd, buffer) + writeDelta(message.oldestCmd, message.oldCmd, buffer) + writeDelta(message.oldCmd, message.newCmd, buffer) + buffer.data[checksumIndex] = CRC.BlockSequenceCRCByte(buffer.data, checksumIndex + 1, + buffer.cursize - checksumIndex - 1, sequence) + } + + /** Includes the client opcode, CRC byte, frame marker, and all three command deltas. */ + fun size(message: MoveMessage): Int = 6 + deltaSize(flags(zero, message.oldestCmd)) + + deltaSize(flags(message.oldestCmd, message.oldCmd)) + deltaSize(flags(message.oldCmd, message.newCmd)) + + fun read(buffer: MessageReader, sequence: Int): MoveMessage { + require(sequence >= 0) { "Movement sequence must be non-negative" } + val checksum = buffer.readUnsignedByte() + val payloadStart = buffer.readcount + val frame = buffer.readInt() + val oldest = readDelta(zero, buffer) + val old = readDelta(oldest, buffer) + val newest = readDelta(old, buffer) + val calculated = CRC.BlockSequenceCRCByte(buffer.data, payloadStart, buffer.readcount - payloadStart, sequence).toInt() and 255 + return MoveMessage(frame, oldest, old, newest, calculated == checksum) + } + + private fun flags(from: UserCommand, to: UserCommand): Int = + (if (from.angles.pitch != to.angles.pitch) CM_ANGLE1 else 0) or + (if (from.angles.yaw != to.angles.yaw) CM_ANGLE2 else 0) or + (if (from.angles.roll != to.angles.roll) CM_ANGLE3 else 0) or + (if (from.forwardmove != to.forwardmove) CM_FORWARD else 0) or + (if (from.sidemove != to.sidemove) CM_SIDE else 0) or + (if (from.upmove != to.upmove) CM_UP else 0) or + (if (from.buttons != to.buttons) CM_BUTTONS else 0) or + (if (from.impulse != to.impulse) CM_IMPULSE else 0) + + private fun deltaSize(flags: Int): Int { + val shorts = flags and (CM_ANGLE1 or CM_ANGLE2 or CM_ANGLE3 or CM_FORWARD or CM_SIDE or CM_UP) + val bytes = flags and (CM_BUTTONS or CM_IMPULSE) + return 3 + 2 * Integer.bitCount(shorts) + Integer.bitCount(bytes) + } + + private fun writeDelta(from: UserCommand, command: UserCommand, buffer: sizebuf_t) { + val flags = flags(from, command) + buffer.writeByte(flags.toByte()) + if (flags and CM_ANGLE1 != 0) buffer.writeShort(command.angles.pitch) + if (flags and CM_ANGLE2 != 0) buffer.writeShort(command.angles.yaw) + if (flags and CM_ANGLE3 != 0) buffer.writeShort(command.angles.roll) + if (flags and CM_FORWARD != 0) buffer.writeShort(command.forwardmove) + if (flags and CM_SIDE != 0) buffer.writeShort(command.sidemove) + if (flags and CM_UP != 0) buffer.writeShort(command.upmove) + if (flags and CM_BUTTONS != 0) buffer.writeByte(command.buttons.toByte()) + if (flags and CM_IMPULSE != 0) buffer.writeByte(command.impulse.toByte()) + buffer.writeByte(command.msec.toByte()); buffer.writeByte(command.lightlevel.toByte()) + } + + private fun readDelta(from: UserCommand, buffer: MessageReader): UserCommand { + val flags = buffer.readUnsignedByte() + // Read current angles, movement, buttons, then the always-present time and light level. + val pitch = if (flags and CM_ANGLE1 != 0) buffer.readSignedShort() else from.angles.pitch + val yaw = if (flags and CM_ANGLE2 != 0) buffer.readSignedShort() else from.angles.yaw + val roll = if (flags and CM_ANGLE3 != 0) buffer.readSignedShort() else from.angles.roll + val forward = if (flags and CM_FORWARD != 0) buffer.readSignedShort() else from.forwardmove + val side = if (flags and CM_SIDE != 0) buffer.readSignedShort() else from.sidemove + val up = if (flags and CM_UP != 0) buffer.readSignedShort() else from.upmove + val buttons = if (flags and CM_BUTTONS != 0) buffer.readUnsignedByte() else from.buttons + val impulse = if (flags and CM_IMPULSE != 0) buffer.readUnsignedByte() else from.impulse + return UserCommand(buffer.readUnsignedByte(), buttons, CommandAngles(pitch, yaw, roll), forward, side, up, impulse, buffer.readUnsignedByte()) + } +} diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index eb261a1b..11612063 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -136,36 +136,36 @@ public static Stream createTestData() { testMessages.add(new Object[]{"client.StringCmdMessage.highbit", new StringCmdMessage("say caf\u00e9\n\u0080\u00fe")}); // delta compressed // empty one - testMessages.add(new Object[]{"client.MoveMessage.empty", new MoveMessage(false, 1, new usercmd_t(), new usercmd_t(), new usercmd_t(), 1)}); + testMessages.add(new Object[]{"client.MoveMessage.empty", MoveMessage.fromInput(false, 1, new usercmd_t(), new usercmd_t(), new usercmd_t())}); // full blown usercmd_t oldestCmd = new usercmd_t((byte) 50, (byte) 5, new short[]{(short) 1, (short) 2, (short) 3}, (short) 10, (short) 20, (short) 30, (byte) 1, (byte) 3); usercmd_t oldCmd = new usercmd_t((byte) 100, (byte) 15, new short[]{(short) 4, (short) 5, (short) 6}, (short) 11, (short) 21, (short) 31, (byte) 5, (byte) 7); usercmd_t newCmd = new usercmd_t((byte) 150, (byte) 25, new short[]{(short) 8, (short) 9, (short) 0}, (short) 12, (short) 22, (short) 32, (byte) 9, (byte) 0); - testMessages.add(new Object[]{"client.MoveMessage.full", new MoveMessage(false, 1, oldestCmd, oldCmd, newCmd, 1)}); - // invalid one (currentSequence during serialization != currentSequence during deserialization) - testMessages.add(new Object[]{"client.MoveMessage.invalid", new MoveMessage(false, INVALID_FRAME_INDEX, oldestCmd, oldCmd, newCmd, 2)}); - return testMessages.stream().map(values -> Arguments.of(values[0], values[1])); + testMessages.add(new Object[]{"client.MoveMessage.full", MoveMessage.fromInput(false, 1, oldestCmd, oldCmd, newCmd)}); + // Invalid checksum: encode this fixture with sequence 2 and decode it with sequence 1. + testMessages.add(new Object[]{"client.MoveMessage.invalid", MoveMessage.fromInput(false, INVALID_FRAME_INDEX, oldestCmd, oldCmd, newCmd), 2}); + return testMessages.stream().map(values -> Arguments.of(values[0], values[1], values.length > 2 ? values[2] : 1)); } - private sizebuf_t writeMessage(NetworkMessage message) { + private sizebuf_t writeMessage(NetworkMessage message, int sequence) { sizebuf_t buffer = new sizebuf_t(); byte[] data = new byte[SIZE]; buffer.init(data, SIZE); - message.writeTo(buffer); + message.writeTo(buffer, sequence); return buffer; } @ParameterizedTest(name = "{index}, {0} {1}") @MethodSource("createTestData") - public void testMessageSize(String testName, NetworkMessage message) { - sizebuf_t buffer = writeMessage(message); + public void testMessageSize(String testName, NetworkMessage message, int sequence) { + sizebuf_t buffer = writeMessage(message, sequence); assertEquals(buffer.cursize, message.getSize(), "Message size != buffer size"); } @ParameterizedTest(name = "{index}, {0} {1}") @MethodSource("createTestData") - public void testBufferFullyRead(String testName, NetworkMessage message) { - sizebuf_t buffer = writeMessage(message); + public void testBufferFullyRead(String testName, NetworkMessage message, int sequence) { + sizebuf_t buffer = writeMessage(message, sequence); if (message instanceof ServerMessage) ServerMessage.parseFromBuffer(buffer); else { //if (message instanceof ClientMessage) @@ -176,8 +176,8 @@ public void testBufferFullyRead(String testName, NetworkMessage message) { @ParameterizedTest(name = "{index}, {0} {1}") @MethodSource("createTestData") - public void testSerializationDeserializationEquality(String testName, NetworkMessage message) { - sizebuf_t buffer = writeMessage(message); + public void testSerializationDeserializationEquality(String testName, NetworkMessage message, int sequence) { + sizebuf_t buffer = writeMessage(message, sequence); final NetworkMessage parsed; if (message instanceof ServerMessage) @@ -185,7 +185,15 @@ public void testSerializationDeserializationEquality(String testName, NetworkMes else parsed = ClientMessage.parseFromBuffer(buffer, 1); - if (message instanceof NopMessage) { + if (message instanceof MoveMessage) { + MoveMessage expected = (MoveMessage) message; + MoveMessage actual = (MoveMessage) parsed; + // Checksum validity is decode metadata; compare the movement payload separately. + assertEquals(expected.getLastReceivedFrame(), actual.getLastReceivedFrame()); + assertEquals(expected.getOldestCmd(), actual.getOldestCmd()); + assertEquals(expected.getOldCmd(), actual.getOldCmd()); + assertEquals(expected.getNewCmd(), actual.getNewCmd()); + } else if (message instanceof NopMessage) { System.err.println("Skipping test for " + message.getClass()); } else { assertEquals(message, parsed, "Message is different after serialization/deserialization"); @@ -193,14 +201,14 @@ public void testSerializationDeserializationEquality(String testName, NetworkMes // client move message crc validation if (parsed instanceof MoveMessage) { - assertEquals((((MoveMessage) parsed).lastReceivedFrame != INVALID_FRAME_INDEX), ((MoveMessage) parsed).valid); + assertEquals((((MoveMessage) parsed).getLastReceivedFrame() != INVALID_FRAME_INDEX), ((MoveMessage) parsed).getChecksumValid()); } } @ParameterizedTest(name = "{index}, {0} {1}") @MethodSource("createTestData") - public void testQuakeNetworkProtocol34Compatibility(String testName, NetworkMessage message) { - sizebuf_t buffer = writeMessage(message); + public void testQuakeNetworkProtocol34Compatibility(String testName, NetworkMessage message, int sequence) { + sizebuf_t buffer = writeMessage(message, sequence); try (InputStream inputStream = getClass().getResourceAsStream(testName + ".bin")) { final byte[] quake34data = inputStream.readAllBytes(); assertEquals(quake34data.length, buffer.cursize, "Message size is different from quake34 protocol"); @@ -218,8 +226,8 @@ public void testQuakeNetworkProtocol34Compatibility(String testName, NetworkMess @Disabled("Utility test used only to regenerate protocol fixture binaries manually.") @ParameterizedTest(name = "{index}, {0} {1}") @MethodSource("createTestData") - public void saveBinaryMessage(String testName, NetworkMessage message) { - sizebuf_t buffer = writeMessage(message); + public void saveBinaryMessage(String testName, NetworkMessage message, int sequence) { + sizebuf_t buffer = writeMessage(message, sequence); try (OutputStream out = new FileOutputStream(testName)) { out.write(buffer.data, 0, buffer.cursize); } catch (IOException e) { diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt index 16604ce5..0770291d 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ClientMessageCodecTest.kt @@ -34,22 +34,22 @@ class ClientMessageCodecTest { @Test fun `movement delegation preserves CRC boundaries after a string and before another message`() { val prefix = StringCmdMessage("new") - val movement = MoveMessage(false, 7, usercmd_t(), usercmd_t(), usercmd_t(), 73) + val movement = MoveMessage.fromInput(false, 7, usercmd_t(), usercmd_t(), usercmd_t()) val suffix = UserInfoMessage("\\name\\player") val values = listOf(prefix, movement, suffix) val buffer = sizebuf_t(values.sumOf(ClientMessageCodec::size)) - values.forEach { ClientMessageCodec.write(it, buffer) } + values.forEach { ClientMessageCodec.write(it, buffer, 73) } assertEquals(buffer.maxsize, buffer.cursize) val decoded = body(buffer.data, 73) as MessageDecodeResult.Decoded assertEquals(values, decoded.messages) - assertTrue((decoded.messages[1] as MoveMessage).valid) + assertTrue((decoded.messages[1] as MoveMessage).checksumValid) // CRC policy belongs to the controller: a corrupted movement CRC remains structurally decodable. val badCRC = buffer.data.clone() val checksumOffset = prefix.size + 1 badCRC[checksumOffset] = (badCRC[checksumOffset].toInt() xor 1).toByte() val corrupted = body(badCRC, 73) as MessageDecodeResult.Decoded - assertFalse((corrupted.messages[1] as MoveMessage).valid) + assertFalse((corrupted.messages[1] as MoveMessage).checksumValid) assertEquals(suffix, corrupted.messages.last()) } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt index 529ff857..681f8af9 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageBodyCodecTest.kt @@ -14,22 +14,22 @@ import org.junit.jupiter.params.provider.MethodSource /** Message decoding deliberately requires no engine, Cvar, or logger initialization. */ class MessageBodyCodecTest { - private fun decode(bytes: ByteArray, client: Boolean = false): MessageDecodeResult<*> { - val wire = PacketCodec.encodeSequenced(1, 0, if (client) 7 else null, PacketBytes.copyOf(bytes)) + private fun decode(bytes: ByteArray, client: Boolean = false, sequence: Int = 1): MessageDecodeResult<*> { + val wire = PacketCodec.encodeSequenced(sequence, 0, if (client) 7 else null, PacketBytes.copyOf(bytes)) val envelope = (PacketCodec.decode(wire, if (client) PacketDirection.FROM_CLIENT else PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope - return if (client) MessageBodyCodec.decodeClient(envelope, 1) else MessageBodyCodec.decodeServer(envelope) + return if (client) MessageBodyCodec.decodeClient(envelope, sequence) else MessageBodyCodec.decodeServer(envelope) } @ParameterizedTest(name = "{0} complete fixture and truncated prefixes") @MethodSource("jake2.qcommon.network.messages.NetworkMessageSizeTest#createTestData") - fun `valid fixtures decode and every incomplete message prefix rejects`(name: String, message: NetworkMessage) { + fun `valid fixtures decode and every incomplete message prefix rejects`(name: String, message: NetworkMessage, sequence: Int) { val bytes = javaClass.getResourceAsStream("/jake2/qcommon/network/messages/$name.bin")!!.use { it.readBytes() } val client = message is ClientMessage - assertInstanceOf(MessageDecodeResult.Decoded::class.java, decode(bytes, client), name) + assertInstanceOf(MessageDecodeResult.Decoded::class.java, decode(bytes, client, sequence), name) assertInstanceOf(MessageDecodeResult.Decoded::class.java, decode(byteArrayOf(), client), "Header-only channel packets remain valid") for (length in 1 until bytes.size) { - assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(bytes.copyOf(length), client), "$name prefix $length") + assertInstanceOf(MessageDecodeResult.Rejected::class.java, decode(bytes.copyOf(length), client, sequence), "$name prefix $length") } } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt new file mode 100644 index 00000000..350f0998 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt @@ -0,0 +1,90 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.network.messages.client.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import jake2.qcommon.usercmd_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MovementCodecTest { + private fun write(message: MoveMessage, sequence: Int): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer, sequence) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray, sequence: Int): MoveMessage { + val reader = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ClientMessageCodec.read(reader, sequence) as MoveMessage + assertEquals(bytes.size, reader.readcount) + reader.data.fill(0) + return message + } + + @Test + fun `all delta flags preserve signed fields inherited fields and explicit resets`() { + for (flags in 0..255) { + val command = UserCommand(255, if (flags and CM_BUTTONS != 0) 128 else 0, + CommandAngles(if (flags and CM_ANGLE1 != 0) -32768 else 0, + if (flags and CM_ANGLE2 != 0) -1 else 0, if (flags and CM_ANGLE3 != 0) 32767 else 0), + if (flags and CM_FORWARD != 0) -32768 else 0, if (flags and CM_SIDE != 0) 32767 else 0, + if (flags and CM_UP != 0) -1 else 0, if (flags and CM_IMPULSE != 0) 255 else 0, 254) + val movement = MoveMessage(-1, command, command.copy(msec = 1, lightlevel = 2), UserCommand()) + val bytes = write(movement, 17) + assertEquals(flags, bytes[6].toInt() and 255) + assertEquals(movement, read(bytes, 17)) + } + for (value in 0..255) { + val command = UserCommand(msec = value, buttons = value, impulse = value, lightlevel = value) + val movement = MoveMessage(1, command, command, command) + assertEquals(movement, read(write(movement, 1), 1)) + } + } + + @Test + fun `movement owns engine history and exports independent mutable engine commands`() { + val source = usercmd_t().apply { + msec = -1; buttons = -128; impulse = -1; lightlevel = -128 + angles = shortArrayOf(-32768, -1, 32767) + forwardmove = -32768; sidemove = 32767; upmove = -1 + } + val movement = MoveMessage.fromInput(false, 77, source, source, source) + val bytes = write(movement, 17) + val hash = movement.hashCode() + source.clear() + movement.newCmd.toEngineCommand().clear() + assertEquals(hash, movement.hashCode()) + assertEquals(255, movement.newCmd.msec) + assertEquals(-1, movement.newCmd.angles[1]) + assertEquals(movement, read(bytes, 17)) + assertArrayEquals(bytes, write(movement, 17)) + val destination = usercmd_t() + movement.newCmd.copyTo(destination) + assertEquals(-128, destination.buttons.toInt()) + destination.clear() + assertArrayEquals(bytes, write(movement, 17)) + val fullFrame = MoveMessage.fromInput(true, 77, source, source, source) + assertEquals(0, fullFrame.lastReceivedFrame) + assertEquals(fullFrame, read(write(fullFrame, 17), 17)) + } + + @Test + fun `sequence is required explicitly and affects only checksum context`() { + val movement = MoveMessage(77, UserCommand(), UserCommand(), UserCommand(msec = 16, forwardmove = 120)) + val contexts = listOf(1, 2, 17, 1023) + val encoded = contexts.map { write(movement, it) } + assertTrue(encoded.map { it[1] }.distinct().size > 1) + for ((sequence, bytes) in contexts.zip(encoded)) assertEquals(movement, read(bytes, sequence)) + val otherIndex = encoded.indices.first { encoded[it][1] != encoded[0][1] } + assertFalse(read(encoded[0], contexts[otherIndex]).checksumValid) + assertArrayEquals(encoded[0], write(movement.copy(checksumValid = false), contexts[0])) + val buffer = sizebuf_t(movement.size) + assertThrows(IllegalArgumentException::class.java) { movement.writeTo(buffer) } + assertEquals(0, buffer.cursize) + assertThrows(IllegalArgumentException::class.java) { movement.writeTo(buffer, -1) } + assertEquals(0, buffer.cursize) + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt index 73521661..2f052467 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -135,20 +135,20 @@ class PacketCodecTest { fun `legacy body reader retains movement checksum offsets`() { val sequence = 17 val newest = usercmd_t().apply { forwardmove = 120; msec = 16 } - val movement = MoveMessage(false, 12, usercmd_t(), usercmd_t(), newest, sequence) + val movement = MoveMessage.fromInput(false, 12, usercmd_t(), usercmd_t(), newest) val buffer = sizebuf_t(MAX_MSGLEN) buffer.writeInt(sequence) buffer.writeInt(5) buffer.writeShort(42) - movement.writeTo(buffer) + movement.writeTo(buffer, sequence) val envelope = decode(buffer.data.copyOf(buffer.cursize), PacketDirection.FROM_CLIENT) val packet = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), envelope) val parsed = assertInstanceOf(MoveMessage::class.java, (packet.parseBodyFromClient(sequence) as MessageDecodeResult.Decoded).messages.single()) - assertTrue(parsed.valid) + assertTrue(parsed.checksumValid) assertEquals(12, parsed.lastReceivedFrame) - assertEquals(120, parsed.newCmd.forwardmove.toInt()) - assertEquals(16, parsed.newCmd.msec.toInt()) + assertEquals(120, parsed.newCmd.forwardmove) + assertEquals(16, parsed.newCmd.msec) } private fun connectionless(text: String): ByteArray = diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt index c4908785..62ceab3a 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/ReliableChannelTest.kt @@ -223,15 +223,15 @@ class ReliableChannelTest { } val packet = assertInstanceOf(PrepareSendResult.Prepared::class.java, client.prepareSend { sequence, capacity -> assertEquals(1, sequence) - val message = MoveMessage(false, 3, usercmd_t(), usercmd_t(), usercmd_t(), sequence) + val message = MoveMessage.fromInput(false, 3, usercmd_t(), usercmd_t(), usercmd_t()) val buffer = sizebuf_t(capacity) - message.writeTo(buffer) + message.writeTo(buffer, sequence) PacketBytes.copyOf(buffer.data, buffer.cursize) }) client.completeSend(packet, true, 10) val legacy = NetworkPacket.fromEnvelope(NetworkAddress.Local(UUID.randomUUID(), LocalSide.FIRST), envelope(packet, PacketDirection.FROM_CLIENT)) - assertTrue(((legacy.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as MoveMessage).valid) + assertTrue(((legacy.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as MoveMessage).checksumValid) } private fun bytes(vararg values: Int) = PacketBytes.copyOf(ByteArray(values.size) { values[it].toByte() }) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt index e439e5c6..3f684c13 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -5,12 +5,32 @@ import jake2.qcommon.network.netadr_t import jake2.qcommon.network.netchan_t import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.messages.client.StringCmdMessage +import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.network.messages.client.UserCommand import jake2.qcommon.network.protocol.* import jake2.qcommon.network.transport.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class OwnedChannelTest { + @Test + fun `the same immutable movement uses each packet's reserved sequence after reliable data`() { + Session().use { session -> + val movement = MoveMessage(3, UserCommand(), UserCommand(), UserCommand(msec = 16, forwardmove = 120)) + session.client.reliablePending.add(StringCmdMessage("new")) + repeat(2) { index -> + assertTrue(session.client.transmit(listOf(movement))) + val packet = session.receive(session.serverTransport, PacketDirection.FROM_CLIENT) + assertTrue(session.server.accept(packet)) + assertEquals(index + 1, session.server.incoming_sequence) + val body = packet.parseBodyFromClient(session.server.incoming_sequence) as MessageDecodeResult.Decoded + val decoded = body.messages.last() as MoveMessage + assertTrue(decoded.checksumValid) + assertEquals(movement, decoded) + } + } + } + private class Session : AutoCloseable { private val link = LoopbackLink() val clientTransport = NetworkTransport(link.first, link.second.localAddress) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 830881e9..cc7b8b24 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1226,7 +1226,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // if the checksum fails, ignore the rest of the packet - if (!msg.valid) { + if (!msg.checksumValid) { Com.Printf("Invalid crc\n") return } @@ -1250,8 +1250,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) processClientMove(cl, msg.newCmd) } - // copy. - cl.lastcmd.set(msg.newCmd) + // Retain the immutable command state; game callbacks receive separate mutable copies. + msg.newCmd.copyTo(cl.lastcmd) } else { Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") dropClient(cl) @@ -1260,6 +1260,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) } } + private fun processClientMove(cl: ClientNetworkInfo, cmd: UserCommand) = processClientMove(cl, cmd.toEngineCommand()) + fun processClientMove(cl: ClientNetworkInfo, cmd: usercmd_t) { cl.commandMsec -= cmd.msec.toInt() and 0xFF From c7cdf31fef98415b91923785734e4247ac2232f1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 23:29:24 +0100 Subject: [PATCH 45/57] Snapshot entity baselines and preserve their wire delta masks --- .../cake/stages/ingame/ClientEntityManager.kt | 2 +- .../ClientEntityManagerFrameSelectionTest.kt | 32 +++ .../messages/server/ServerMessage.java | 3 - .../messages/server/SpawnBaselineMessage.java | 66 ------- .../messages/server/EntityDeltaCodec.kt | 185 ++++++++++++++++++ .../messages/server/EntityStateSnapshot.kt | 58 ++++++ .../messages/server/ServerMessageCodec.kt | 3 + .../messages/server/SpawnBaselineMessage.kt | 14 ++ .../protocol/EntityBaselineCodecTest.kt | 135 +++++++++++++ 9 files changed, 428 insertions(+), 70 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SpawnBaselineMessage.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/EntityBaselineCodecTest.kt 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 7318c00b..684ef0c9 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 @@ -100,7 +100,7 @@ class ClientEntityManager : Disposable { } fun processBaselineMessage(msg: SpawnBaselineMessage) { - clientEntities[msg.entityState.index].baseline.set(msg.entityState) + msg.entityState.copyTo(clientEntities[msg.entityState.index].baseline) } // Cross-reference: old client iterates dynamic collision entities from diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt index 7be5f06e..0e5f22f4 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt @@ -4,6 +4,10 @@ import jake2.qcommon.network.messages.server.FrameHeaderMessage import jake2.qcommon.network.messages.server.PacketEntitiesMessage import jake2.qcommon.network.messages.server.PlayerInfoMessage import jake2.qcommon.network.messages.server.ServerMessage +import jake2.qcommon.network.messages.server.SpawnBaselineMessage +import jake2.qcommon.network.messages.server.EntityUpdate +import jake2.qcommon.network.messages.server.DeltaEntityHeader +import jake2.qcommon.entity_state_t import jake2.qcommon.player_state_t import jake2.qcommon.sizebuf_t import org.junit.jupiter.api.Assertions.assertEquals @@ -15,6 +19,34 @@ import org.junit.jupiter.api.Test /** Checks frame selection from parsed messages; registers only entity cvars before consumer construction. */ class ClientEntityManagerFrameSelectionTest { + @Test + fun immutableBaselineReconstructsTheFirstEntityFrameWithoutSharingProducerState() { + CakeCvars.registerEntities() + val manager = ClientEntityManager() + try { + val source = entity_state_t(1).apply { + origin = floatArrayOf(1.125f, 2.25f, 3.5f) + angles = floatArrayOf(45f, -90f, 0f) + modelindex = 7; event = 2 + } + val queued = SpawnBaselineMessage(source) + source.clear() + val buffer = sizebuf_t(queued.size).apply { queued.writeTo(this) } + manager.processBaselineMessage(ServerMessage.parseFromBuffer(buffer) as SpawnBaselineMessage) + manager.processServerFrameHeader(FrameHeaderMessage(1, -1, 0, 0, byteArrayOf())) + val updates = PacketEntitiesMessage().apply { this.updates.add(EntityUpdate(DeltaEntityHeader(0, 1), entity_state_t(1))) } + assertTrue(manager.processPacketEntitiesMessage(updates)) + assertArrayEquals(floatArrayOf(1.125f, 2.25f, 3.5f), manager.getEntityOrigin(1)!!.let { floatArrayOf(it.x, it.y, it.z) }) + assertArrayEquals(floatArrayOf(45f, -90f, 0f), manager.getEntityAngles(1)) + manager.forEachCurrentEntityState { + assertEquals(7, it.modelindex) + assertEquals(0, it.event, "Baseline impulse events must not repeat in subsequent entity updates") + } + } finally { + manager.dispose() + } + } + @Test fun shorterAreaBitsClearThePreviousVisibilityTail() { CakeCvars.registerEntities() diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 8fc46178..9e21e3a7 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -148,9 +148,6 @@ static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_ case svc_playerinfo: msg = new PlayerInfoMessage(); break; - case svc_spawnbaseline: - msg = new SpawnBaselineMessage(); - break; case svc_packetentities: msg = new PacketEntitiesMessage(); break; diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java deleted file mode 100644 index 8e0aa498..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SpawnBaselineMessage.java +++ /dev/null @@ -1,66 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.DeltaUtils; -import jake2.qcommon.entity_state_t; -import jake2.qcommon.sizebuf_t; - -/** - * Contains baseline for a single entity, - * sent to a new client when the client is just connected to the server. - * - * Baseline is a snapshot of an entity state at the moment of level instantiation. - */ -public class SpawnBaselineMessage extends LegacyServerMessage { - - public entity_state_t entityState; - private final entity_state_t base = new entity_state_t(); - - public SpawnBaselineMessage() { - super(ServerMessageType.svc_spawnbaseline); - } - - public SpawnBaselineMessage(entity_state_t entityState) { - this(); - this.entityState = entityState; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - DeltaUtils.WriteDeltaEntity(base, entityState, buffer, true, true); - } - - @Override - public void parse(sizebuf_t buffer) { - DeltaEntityHeader header = parseDeltaEntityHeader(buffer); - entityState = parseEntityState(header.number, header.flags, buffer); - } - - @Override - public int getSize() { - return 1 + DeltaUtils.getDeltaSize(base, entityState, true, true); - } - - @Override - public String toString() { - return "SpawnBaselineMessage{" + - "entityState=" + entityState + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (!(o instanceof SpawnBaselineMessage)) - return false; - - SpawnBaselineMessage that = (SpawnBaselineMessage) o; - - return entityState != null ? entityState.equals(that.entityState) : that.entityState == null; - } - - @Override - public int hashCode() { - return entityState != null ? entityState.hashCode() : 0; - } -} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt new file mode 100644 index 00000000..ddfe2050 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt @@ -0,0 +1,185 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.protocol.MessageReadException +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.network.protocol.MessageRejection +import jake2.qcommon.sizebuf_t + +/** Entity delta planning is separate from reading/writing an already-selected wire mask. */ +object EntityDeltaCodec { + private val zero = EntityStateSnapshot() + + fun baseline(state: EntityStateSnapshot): EntityDelta = requireNotNull(plan(zero, state, force = true, newEntity = true)) + + /** Writes part of packetentities: delta from either a baseline or a previous packet entity. */ + fun plan(from: EntityStateSnapshot, to: EntityStateSnapshot, force: Boolean, newEntity: Boolean): EntityDelta? { + require(to.index in 1 until MAX_EDICTS) { "Unset or invalid entity number ${to.index}" } + var flags = if (to.index >= 256) U_NUMBER16 else 0 + if (to.origin.x != from.origin.x) flags = flags or U_ORIGIN1 + if (to.origin.y != from.origin.y) flags = flags or U_ORIGIN2 + if (to.origin.z != from.origin.z) flags = flags or U_ORIGIN3 + if (to.angles.x != from.angles.x) flags = flags or U_ANGLE1 + if (to.angles.y != from.angles.y) flags = flags or U_ANGLE2 + if (to.angles.z != from.angles.z) flags = flags or U_ANGLE3 + if (to.skinNumber != from.skinNumber) flags = flags or when { + Integer.compareUnsigned(to.skinNumber, 256) < 0 -> U_SKIN8 + Integer.compareUnsigned(to.skinNumber, 65536) < 0 -> U_SKIN16 + else -> U_SKIN8 or U_SKIN16 + } + if (to.frame != from.frame) flags = flags or if (to.frame < 256) U_FRAME8 else U_FRAME16 + // Preserve the legacy signed comparisons for effects/renderfx field-width selection. + if (to.effects != from.effects) flags = flags or widthFlags(to.effects, U_EFFECTS8, U_EFFECTS16) + if (to.renderEffects != from.renderEffects) flags = flags or widthFlags(to.renderEffects, U_RENDERFX8, U_RENDERFX16) + if (to.solid != from.solid) flags = flags or U_SOLID + // Event is not delta compressed, just zero compressed: nonzero impulses are sent for one frame. + if (to.event != 0) flags = flags or U_EVENT + if (to.modelIndex != from.modelIndex) flags = flags or U_MODEL + if (to.modelIndex2 != from.modelIndex2) flags = flags or U_MODEL2 + if (to.modelIndex3 != from.modelIndex3) flags = flags or U_MODEL3 + if (to.modelIndex4 != from.modelIndex4) flags = flags or U_MODEL4 + if (to.sound != from.sound) flags = flags or U_SOUND + if (newEntity || to.renderEffects and RF_BEAM != 0) flags = flags or U_OLDORIGIN + flags = when { + flags and -0x1000000 != 0 -> flags or U_MOREBITS3 or U_MOREBITS2 or U_MOREBITS1 + flags and 0x00ff0000 != 0 -> flags or U_MOREBITS2 or U_MOREBITS1 + flags and 0x0000ff00 != 0 -> flags or U_MOREBITS1 + else -> flags + } + return if (flags == 0 && !force) null else EntityDelta(flags, wireState(to, flags)) + } + + /** Compare full engine snapshots first, then retain only selected, quantized payload values. */ + private fun wireState(state: EntityStateSnapshot, flags: Int): EntityStateSnapshot { + fun selected(bit: Int) = flags and bit != 0 + fun coord(value: Float): Float = (value * 8).toInt().toShort().toInt() * 0.125f + fun angle(value: Float): Float = ((value * 256 / 360).toInt() and 255).toByte().toInt() * (180f / 128) + fun byte(value: Int, bit: Int): Int = if (selected(bit)) value and 255 else 0 + return EntityStateSnapshot( + index = state.index, + origin = Vector3f(if (selected(U_ORIGIN1)) coord(state.origin.x) else 0f, + if (selected(U_ORIGIN2)) coord(state.origin.y) else 0f, if (selected(U_ORIGIN3)) coord(state.origin.z) else 0f), + angles = Vector3f(if (selected(U_ANGLE1)) angle(state.angles.x) else 0f, + if (selected(U_ANGLE2)) angle(state.angles.y) else 0f, if (selected(U_ANGLE3)) angle(state.angles.z) else 0f), + oldOrigin = if (selected(U_OLDORIGIN)) Vector3f(coord(state.oldOrigin.x), coord(state.oldOrigin.y), coord(state.oldOrigin.z)) else Vector3f.zero, + modelIndex = byte(state.modelIndex, U_MODEL), modelIndex2 = byte(state.modelIndex2, U_MODEL2), + modelIndex3 = byte(state.modelIndex3, U_MODEL3), modelIndex4 = byte(state.modelIndex4, U_MODEL4), + frame = if (selected(U_FRAME16)) state.frame.toShort().toInt() else byte(state.frame, U_FRAME8), + skinNumber = narrow(state.skinNumber, flags, U_SKIN8, U_SKIN16), + effects = narrow(state.effects, flags, U_EFFECTS8, U_EFFECTS16), + renderEffects = narrow(state.renderEffects, flags, U_RENDERFX8, U_RENDERFX16), + solid = if (selected(U_SOLID)) state.solid.toShort().toInt() else 0, + sound = byte(state.sound, U_SOUND), event = byte(state.event, U_EVENT), + ) + } + + private fun narrow(value: Int, flags: Int, byte: Int, short: Int): Int = when (flags and (byte or short)) { + byte or short -> value + byte -> value and 255 + short -> value.toShort().toInt() + else -> 0 + } + + private fun widthFlags(value: Int, byte: Int, short: Int): Int = when { + value < 256 -> byte + value < 0x8000 -> short + else -> byte or short + } + + fun write(delta: EntityDelta, buffer: sizebuf_t) { + val flags = delta.flags + val state = delta.state + buffer.writeByte(flags.toByte()) + if (flags and U_MOREBITS1 != 0) buffer.writeByte((flags ushr 8).toByte()) + if (flags and U_MOREBITS2 != 0) buffer.writeByte((flags ushr 16).toByte()) + if (flags and U_MOREBITS3 != 0) buffer.writeByte((flags ushr 24).toByte()) + if (flags and U_NUMBER16 != 0) buffer.writeShort(state.index) else buffer.writeByte(state.index.toByte()) + if (flags and U_MODEL != 0) buffer.writeByte(state.modelIndex.toByte()) + if (flags and U_MODEL2 != 0) buffer.writeByte(state.modelIndex2.toByte()) + if (flags and U_MODEL3 != 0) buffer.writeByte(state.modelIndex3.toByte()) + if (flags and U_MODEL4 != 0) buffer.writeByte(state.modelIndex4.toByte()) + if (flags and U_FRAME8 != 0) buffer.writeByte(state.frame.toByte()) + if (flags and U_FRAME16 != 0) buffer.writeShort(state.frame) + writeSized(state.skinNumber, flags, U_SKIN8, U_SKIN16, buffer) + writeSized(state.effects, flags, U_EFFECTS8, U_EFFECTS16, buffer) + writeSized(state.renderEffects, flags, U_RENDERFX8, U_RENDERFX16, buffer) + if (flags and U_ORIGIN1 != 0) buffer.writeCoord(state.origin.x) + if (flags and U_ORIGIN2 != 0) buffer.writeCoord(state.origin.y) + if (flags and U_ORIGIN3 != 0) buffer.writeCoord(state.origin.z) + if (flags and U_ANGLE1 != 0) buffer.writeAngleByte(state.angles.x) + if (flags and U_ANGLE2 != 0) buffer.writeAngleByte(state.angles.y) + if (flags and U_ANGLE3 != 0) buffer.writeAngleByte(state.angles.z) + if (flags and U_OLDORIGIN != 0) { + buffer.writeCoord(state.oldOrigin.x); buffer.writeCoord(state.oldOrigin.y); buffer.writeCoord(state.oldOrigin.z) + } + if (flags and U_SOUND != 0) buffer.writeByte(state.sound.toByte()) + if (flags and U_EVENT != 0) buffer.writeByte(state.event.toByte()) + if (flags and U_SOLID != 0) buffer.writeShort(state.solid) + } + + private fun writeSized(value: Int, flags: Int, byte: Int, short: Int, buffer: sizebuf_t) { + when (flags and (byte or short)) { + byte or short -> buffer.writeInt(value) // Combined skin flags also carry laser colors. + byte -> buffer.writeByte(value.toByte()) + short -> buffer.writeShort(value) + } + } + + /** CL_ParseEntityBits followed by the former CL_ParseDelta, retaining presence and selected widths. */ + fun read(buffer: MessageReader): EntityDelta { + var flags = buffer.readUnsignedByte() + if (flags and U_MOREBITS1 != 0) flags = flags or (buffer.readUnsignedByte() shl 8) + if (flags and U_MOREBITS2 != 0) flags = flags or (buffer.readUnsignedByte() shl 16) + if (flags and U_MOREBITS3 != 0) flags = flags or (buffer.readUnsignedByte() shl 24) + val number = if (flags and U_NUMBER16 != 0) buffer.readUnsignedShort() else buffer.readUnsignedByte() + if (number >= MAX_EDICTS) + throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number $number") + val model = if (flags and U_MODEL != 0) buffer.readUnsignedByte() else 0 + val model2 = if (flags and U_MODEL2 != 0) buffer.readUnsignedByte() else 0 + val model3 = if (flags and U_MODEL3 != 0) buffer.readUnsignedByte() else 0 + val model4 = if (flags and U_MODEL4 != 0) buffer.readUnsignedByte() else 0 + var frame = if (flags and U_FRAME8 != 0) buffer.readUnsignedByte() else 0 + if (flags and U_FRAME16 != 0) frame = buffer.readSignedShort() + val skin = readSized(flags, U_SKIN8, U_SKIN16, buffer) + val effects = readSized(flags, U_EFFECTS8, U_EFFECTS16, buffer) + val render = readSized(flags, U_RENDERFX8, U_RENDERFX16, buffer) + val x = if (flags and U_ORIGIN1 != 0) buffer.readCoord() else 0f + val y = if (flags and U_ORIGIN2 != 0) buffer.readCoord() else 0f + val z = if (flags and U_ORIGIN3 != 0) buffer.readCoord() else 0f + val pitch = if (flags and U_ANGLE1 != 0) buffer.readAngleByte() else 0f + val yaw = if (flags and U_ANGLE2 != 0) buffer.readAngleByte() else 0f + val roll = if (flags and U_ANGLE3 != 0) buffer.readAngleByte() else 0f + val oldOrigin = if (flags and U_OLDORIGIN != 0) buffer.readPosition() else Vector3f.zero + val sound = if (flags and U_SOUND != 0) buffer.readUnsignedByte() else 0 + val event = if (flags and U_EVENT != 0) buffer.readUnsignedByte() else 0 + val solid = if (flags and U_SOLID != 0) buffer.readSignedShort() else 0 + return EntityDelta(flags, EntityStateSnapshot(number, Vector3f(x, y, z), Vector3f(pitch, yaw, roll), + oldOrigin, model, model2, model3, model4, frame, skin, effects, render, solid, sound, event)) + } + + private fun readSized(flags: Int, byte: Int, short: Int, buffer: MessageReader): Int = when (flags and (byte or short)) { + byte or short -> buffer.readInt() + byte -> buffer.readUnsignedByte() + short -> buffer.readSignedShort() + else -> 0 + } + + fun size(delta: EntityDelta): Int { + val flags = delta.flags + var size = 1 + (if (flags and U_NUMBER16 != 0) 2 else 1) + size += Integer.bitCount(flags and (U_MOREBITS1 or U_MOREBITS2 or U_MOREBITS3)) + size += Integer.bitCount(flags and (U_MODEL or U_MODEL2 or U_MODEL3 or U_MODEL4 or U_FRAME8 or U_SOUND or U_EVENT or U_ANGLE1 or U_ANGLE2 or U_ANGLE3)) + size += 2 * Integer.bitCount(flags and (U_ORIGIN1 or U_ORIGIN2 or U_ORIGIN3 or U_FRAME16 or U_SOLID)) + size += sizedLength(flags, U_SKIN8, U_SKIN16) + sizedLength(flags, U_EFFECTS8, U_EFFECTS16) + sizedLength(flags, U_RENDERFX8, U_RENDERFX16) + if (flags and U_OLDORIGIN != 0) size += 6 + return size + } + + private fun sizedLength(flags: Int, byte: Int, short: Int): Int = when (flags and (byte or short)) { + byte or short -> 4 + byte -> 1 + short -> 2 + else -> 0 + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt new file mode 100644 index 00000000..eded1d1e --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt @@ -0,0 +1,58 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.MAX_EDICTS +import jake2.qcommon.Defines.U_NUMBER16 +import jake2.qcommon.entity_state_t +import jake2.qcommon.math.Vector3f + +/** Information conveyed by an entity update that the client needs to render an entity. Owns all vector values. */ +data class EntityStateSnapshot( + /** Edict index; identity is explicit and must be retained when copying into engine storage. */ + val index: Int = 0, + val origin: Vector3f = Vector3f.zero, + val angles: Vector3f = Vector3f.zero, + /** Previous origin for lerping. */ + val oldOrigin: Vector3f = Vector3f.zero, + val modelIndex: Int = 0, + /** Additional models such as weapons and CTF flags. */ + val modelIndex2: Int = 0, + val modelIndex3: Int = 0, + val modelIndex4: Int = 0, + val frame: Int = 0, + val skinNumber: Int = 0, + /** Effects use a 32-bit bit pattern even though engine storage is a signed Int. */ + val effects: Int = 0, + val renderEffects: Int = 0, + /** Prediction bounds: 8*(bits 0..4) is x/y radius, 8*(bits 5..9) is z down, 8*(bits 10..15) is z up; linkentity sets this. */ + val solid: Int = 0, + /** Looping sound index, transmitted to guarantee shutoff. */ + val sound: Int = 0, + /** Impulse events (muzzle flashes, footsteps, etc.) last one frame and are automatically cleared each frame. */ + val event: Int = 0, +) { + init { require(index in 0 until MAX_EDICTS) { "Invalid entity index $index" } } + + fun copyTo(target: entity_state_t) { + target.index = index + copy(origin, target.origin); copy(angles, target.angles); copy(oldOrigin, target.old_origin) + target.modelindex = modelIndex; target.modelindex2 = modelIndex2 + target.modelindex3 = modelIndex3; target.modelindex4 = modelIndex4 + target.frame = frame; target.skinnum = skinNumber; target.effects = effects; target.renderfx = renderEffects + target.solid = solid; target.sound = sound; target.event = event + } + + private fun copy(value: Vector3f, target: FloatArray) { target[0] = value.x; target[1] = value.y; target[2] = value.z } + + companion object { + @JvmStatic fun fromEngine(state: entity_state_t): EntityStateSnapshot = EntityStateSnapshot( + state.index, Vector3f(state.origin), Vector3f(state.angles), Vector3f(state.old_origin), + state.modelindex, state.modelindex2, state.modelindex3, state.modelindex4, + state.frame, state.skinnum, state.effects, state.renderfx, state.solid, state.sound, state.event, + ) + } +} + +/** Field presence and widths are part of the wire value; unselected fields are zero in planned and received states. */ +data class EntityDelta(val flags: Int, val state: EntityStateSnapshot) { + init { require(state.index < 256 || flags and U_NUMBER16 != 0) { "Entity number requires a 16-bit header" } } +} 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 4894c440..880f5b0c 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 @@ -37,6 +37,7 @@ object ServerMessageCodec { message.origin?.let { buffer.writeCoord(it.x); buffer.writeCoord(it.y); buffer.writeCoord(it.z) } } is TEMessage -> TemporaryEntityCodec.writeProperties(message, buffer) + is SpawnBaselineMessage -> EntityDeltaCodec.write(message.delta, buffer) is InventoryMessage -> repeat(MAX_ITEMS) { buffer.writeShort(message[it]) } is DownloadMessage.Chunk -> { buffer.writeShort(message.data.size) @@ -77,6 +78,7 @@ object ServerMessageCodec { is DownloadMessage.Failure -> 4 is FrameHeaderMessage -> 11 + message.areaBits.size is TEMessage -> TemporaryEntityCodec.size(message) + is SpawnBaselineMessage -> 1 + EntityDeltaCodec.size(message.delta) DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 is LegacyServerMessage -> message.size else -> error("Unsupported server message ${message.javaClass.name}") @@ -108,6 +110,7 @@ object ServerMessageCodec { ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_sound -> readSound(buffer) ServerMessageType.svc_temp_entity -> TemporaryEntityCodec.read(buffer) + 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) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SpawnBaselineMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SpawnBaselineMessage.kt new file mode 100644 index 00000000..0545f78f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SpawnBaselineMessage.kt @@ -0,0 +1,14 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.entity_state_t + +/** + * Contains the baseline for a single entity, sent to a new client when it has just connected to the server. + * A baseline is a snapshot of the entity state at the moment of level instantiation. + * Received values retain their original delta mask instead of planning a new delta during encoding. + */ +data class SpawnBaselineMessage(val delta: EntityDelta) : ServerMessage(ServerMessageType.svc_spawnbaseline) { + val entityState: EntityStateSnapshot get() = delta.state + constructor(state: EntityStateSnapshot) : this(EntityDeltaCodec.baseline(state)) + constructor(state: entity_state_t) : this(EntityStateSnapshot.fromEngine(state)) +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/EntityBaselineCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/EntityBaselineCodecTest.kt new file mode 100644 index 00000000..0033584d --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/EntityBaselineCodecTest.kt @@ -0,0 +1,135 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.DeltaUtils +import jake2.qcommon.entity_state_t +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class EntityBaselineCodecTest { + private fun write(message: SpawnBaselineMessage): ByteArray { + val buffer = sizebuf_t(message.size) + message.writeTo(buffer) + assertEquals(buffer.maxsize, buffer.cursize) + return buffer.data + } + + private fun read(bytes: ByteArray): SpawnBaselineMessage { + val buffer = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ServerMessageCodec.read(buffer) as SpawnBaselineMessage + assertEquals(bytes.size, buffer.readcount) + buffer.data.fill(0) + return message + } + + @Test + fun `baseline field widths match the legacy writer including signed and unsigned boundaries`() { + val fields: List<(entity_state_t, Int) -> Unit> = listOf( + { state, value -> state.frame = value }, { state, value -> state.skinnum = value }, + { state, value -> state.effects = value }, { state, value -> state.renderfx = value }, + { state, value -> state.solid = value }, { state, value -> state.modelindex = value }, + { state, value -> state.modelindex2 = value; state.modelindex3 = value; state.modelindex4 = value }, + { state, value -> state.sound = value; state.event = value }, + ) + for (index in listOf(1, 255, 256, MAX_EDICTS - 1)) for ((field, change) in fields.withIndex()) { + for (value in listOf(Int.MIN_VALUE, -1, 0, 1, 255, 256, 32767, 32768, 65535, 65536, 0x12345678, Int.MAX_VALUE)) { + val state = entity_state_t(index).also { change(it, value) } + state.origin = floatArrayOf(-0.124f, 0.249f, 4096f) + state.angles = floatArrayOf(-90f, 45f, 178.59375f) + state.old_origin = floatArrayOf(1.125f, -2.25f, 3.5f) + val legacy = sizebuf_t(128).apply { + writeByte(14) + DeltaUtils.WriteDeltaEntity(entity_state_t(), state, this, true, true) + }.let { it.data.copyOf(it.cursize) } + val message = SpawnBaselineMessage(state) + val encoded = write(message) + assertArrayEquals(legacy, encoded, "Entity $index field $field value $value") + assertEquals(message, read(encoded)) + assertArrayEquals(encoded, write(read(encoded))) + } + } + } + + @Test + fun `shared planner retains force event and beam rules across entity header widths`() { + for (index in listOf(1, 256)) for (force in listOf(false, true)) for (isNew in listOf(false, true)) { + for (variant in 0..2) { + val from = entity_state_t(index).apply { modelindex = 7; event = 1 } + val to = from.getClone() + if (variant == 1) { to.modelindex = 0; to.event = 0; to.origin[0] = 10f } + if (variant == 2) to.renderfx = RF_BEAM + val legacy = sizebuf_t(128).apply { DeltaUtils.WriteDeltaEntity(from, to, this, force, isNew) } + val planned = EntityDeltaCodec.plan(EntityStateSnapshot.fromEngine(from), EntityStateSnapshot.fromEngine(to), force, isNew) + val actual = if (planned == null) byteArrayOf() else sizebuf_t(EntityDeltaCodec.size(planned)).apply { + EntityDeltaCodec.write(planned, this) + assertEquals(maxsize, cursize) + }.data + assertArrayEquals(legacy.data.copyOf(legacy.cursize), actual) + if (planned != null) assertEquals(planned, EntityDeltaCodec.read(MessageReader(PacketBytes.copyOf(actual), 0))) + } + } + val unchanged = EntityStateSnapshot(index = 1) + assertNull(EntityDeltaCodec.plan(unchanged, unchanged, false, false)) + assertEquals(0, EntityDeltaCodec.plan(unchanged, unchanged, true, false)!!.flags) + val wideIdentity = EntityStateSnapshot(index = 256) + // Preserve the legacy number-width header even for an otherwise unchanged unforced entity. + assertEquals(U_NUMBER16 or U_MOREBITS1, EntityDeltaCodec.plan(wideIdentity, wideIdentity, false, false)!!.flags) + } + + @Test + fun `received baselines retain explicit zero fields extension bytes and selected signed widths`() { + val flags = U_MOREBITS1 or U_MOREBITS2 or U_MOREBITS3 or U_NUMBER16 or U_MODEL or U_FRAME16 or U_SKIN16 or U_EFFECTS16 or U_RENDERFX16 or U_SOLID + val state = entity_state_t(1).apply { frame = -1; skinnum = -1; effects = -1; renderfx = -1; solid = -1 } + val wide = sizebuf_t(128).apply { writeByte(14); DeltaUtils.writeEntityDelta(flags, 1, state, this) }.let { it.data.copyOf(it.cursize) } + for (bytes in listOf(wide, byteArrayOf(14, 128.toByte(), 0, 1), + byteArrayOf(14, 128.toByte(), 128.toByte(), 128.toByte(), 0, 1), + byteArrayOf(14, 128.toByte(), 128.toByte(), 128.toByte(), 128.toByte(), 1))) { + assertArrayEquals(bytes, write(read(bytes))) + } + val decoded = read(wide) + assertEquals(flags, decoded.delta.flags) + assertEquals(0, decoded.entityState.modelIndex) + assertEquals(-1, decoded.entityState.frame) + assertEquals(-1, decoded.entityState.skinNumber) + } + + @Test + fun `baseline snapshots own engine arrays and export all fields into independent engine storage`() { + val source = entity_state_t(7).apply { + origin = floatArrayOf(1f, 2f, 3f); angles = floatArrayOf(0f, 45f, 90f); old_origin = floatArrayOf(4f, 5f, 6f) + modelindex = 1; modelindex2 = 2; modelindex3 = 3; modelindex4 = 4 + frame = 17; skinnum = 23; effects = 32; renderfx = RF_BEAM; solid = 7; sound = 5; event = 2 + } + val message = SpawnBaselineMessage(source) + val original = source.getClone() + val bytes = write(message) + val hash = message.hashCode() + source.clear() + message.entityState.origin.toArray().fill(99f) + val destination = entity_state_t() + message.entityState.copyTo(destination) + assertEquals(original, destination) + destination.clear() + assertEquals(hash, message.hashCode()) + assertEquals(Vector3f(1f, 2f, 3f), message.entityState.origin) + assertArrayEquals(bytes, write(message.copy())) + assertEquals(message, read(bytes)) + } + + @Test + fun `invalid baseline entity indices reject the complete body before dispatch`() { + for (index in listOf(MAX_EDICTS, 65535)) { + val body = sizebuf_t(16).apply { + writeByte(7); writeByte(14) + DeltaUtils.writeEntityHeader(U_MOREBITS1 or U_NUMBER16, index, this) + }.let { PacketBytes.copyOf(it.data, it.cursize) } + val wire = PacketCodec.encodeSequenced(1, 0, null, body) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + assertEquals(MessageRejection.INVALID_VALUE, (MessageBodyCodec.decodeServer(envelope) as MessageDecodeResult.Rejected).reason) + } + } +} From b92825f0e684cea084bb0dd1d50353402b5c21c4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 23:42:09 +0100 Subject: [PATCH 46/57] Make packet entity deltas immutable Kotlin values --- .../java/jake2/server/GameImportsTest.java | 21 ++-- .../cake/stages/ingame/ClientEntityManager.kt | 8 +- .../ClientEntityManagerFrameSelectionTest.kt | 47 +++++++- ...ientEntityManagerInterpolationGuardTest.kt | 21 ++-- .../messages/server/DeltaEntityHeader.java | 37 ------ .../network/messages/server/EntityUpdate.java | 69 ----------- .../server/PacketEntitiesMessage.java | 102 ---------------- .../messages/server/ServerMessage.java | 114 ------------------ .../messages/server/EntityDeltaCodec.kt | 27 ++++- .../messages/server/EntityStateSnapshot.kt | 30 ++++- .../messages/server/PacketEntitiesCodec.kt | 35 ++++++ .../messages/server/PacketEntitiesMessage.kt | 41 +++++++ .../messages/server/ServerMessageCodec.kt | 3 + .../java/jake2/qcommon/DeltaUtils.java | 0 .../messages/NetworkMessageSizeTest.java | 8 +- .../network/protocol/MessageReencodingTest.kt | 25 ++-- .../protocol/PacketEntitiesCodecTest.kt | 83 +++++++++++++ .../java/jake2/server/GameImportsImpl.java | 12 +- 18 files changed, 306 insertions(+), 377 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/DeltaEntityHeader.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/EntityUpdate.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesCodec.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt rename qcommon/src/{main => test}/java/jake2/qcommon/DeltaUtils.java (100%) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketEntitiesCodecTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 27107d1e..333b32c4 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -178,9 +178,9 @@ public void clientSnapshotsKeepIndependentGameHistory() { var initial = List.copyOf(first.buildClientFrameMessages(firstClient)); Assertions.assertEquals(3, initial.size()); Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).getLastFrame()); - EntityUpdate initialUpdate = ((PacketEntitiesMessage) initial.get(2)).updates.get(0); - Assertions.assertTrue(initialUpdate.force); - Assertions.assertEquals(11, initialUpdate.newState.modelindex); + EntityUpdate.Change initialUpdate = (EntityUpdate.Change) ((PacketEntitiesMessage) initial.get(2)).getUpdates().get(0); + Assertions.assertNotEquals(0, initialUpdate.getDelta().getFlags() & Defines.U_OLDORIGIN); + Assertions.assertEquals(11, initialUpdate.getDelta().getState().getModelIndex()); second.buildClientFrameMessages(secondClient); // Each delta must refer to its own saved snapshot, not the live entity or the other Game. @@ -195,26 +195,25 @@ public void clientSnapshotsKeepIndependentGameHistory() { FrameHeaderMessage header = (FrameHeaderMessage) messages.get(0); Assertions.assertEquals(2, header.getFrameNumber()); Assertions.assertEquals(1, header.getLastFrame()); - var updates = ((PacketEntitiesMessage) messages.get(2)).updates; + var updates = ((PacketEntitiesMessage) messages.get(2)).getUpdates(); Assertions.assertEquals(1, updates.size()); - Assertions.assertEquals(oldModel, updates.get(0).oldState.modelindex); - Assertions.assertEquals(oldModel + 1, updates.get(0).newState.modelindex); - Assertions.assertFalse(updates.get(0).force); + Assertions.assertNotEquals(0, ((EntityUpdate.Change) updates.get(0)).getDelta().getFlags() & Defines.U_MODEL); + Assertions.assertEquals(oldModel + 1, ((EntityUpdate.Change) updates.get(0)).getDelta().getState().getModelIndex()); } firstClient.lastReceivedFrame = 2; first.frameNum = 3; firstClient.edict.s.modelindex = 0; var removed = List.copyOf(first.buildClientFrameMessages(firstClient)); - EntityUpdate removal = ((PacketEntitiesMessage) removed.get(2)).updates.get(0); - Assertions.assertEquals(1, removal.header.number); - Assertions.assertNotEquals(0, removal.header.flags & Defines.U_REMOVE); + EntityUpdate.Removal removal = (EntityUpdate.Removal) ((PacketEntitiesMessage) removed.get(2)).getUpdates().get(0); + Assertions.assertEquals(1, removal.getNumber()); + Assertions.assertNotEquals(0, removal.getFlags() & Defines.U_REMOVE); // An acknowledgement outside the retained frame window falls back to a baseline update. second.frameNum += Defines.UPDATE_BACKUP; var stale = List.copyOf(second.buildClientFrameMessages(secondClient)); Assertions.assertEquals(-1, ((FrameHeaderMessage) stale.get(0)).getLastFrame()); - Assertions.assertTrue(((PacketEntitiesMessage) stale.get(2)).updates.get(0).force); + Assertions.assertNotEquals(0, ((EntityUpdate.Change) ((PacketEntitiesMessage) stale.get(2)).getUpdates().get(0)).getDelta().getFlags() & Defines.U_OLDORIGIN); } private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { 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 684ef0c9..664f0f1c 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 @@ -171,12 +171,12 @@ class ClientEntityManager : Disposable { // Process updates in order for (update in msg.updates) { - val newnum = update.header.number + val newnum = update.number // Bring old pointer up to the current newnum, copying unchanged copyUnchangedUntil(newnum) - if ((update.header.flags and Defines.U_REMOVE) != 0) { + if (update !is EntityUpdate.Change) { // Removal: ensure we are at the entity being removed // If due to some earlier mismatch we are still behind, pull forward copying unchanged copyUnchangedUntil(newnum) @@ -214,7 +214,7 @@ class ClientEntityManager : Disposable { /** * Update entity based on the delta [update] received from server and it's previous state. */ - private fun DeltaEntity(frame: ClientFrame, newnum: Int, old: entity_state_t, update: EntityUpdate?) { + private fun DeltaEntity(frame: ClientFrame, newnum: Int, old: entity_state_t, update: EntityUpdate.Change?) { val entity: ClientEntity = clientEntities[newnum] // parse_entities now points to the last state from last frame val newState: entity_state_t = cl_parse_entities[parse_entities and (Defines.MAX_PARSE_ENTITIES - 1)] @@ -227,7 +227,7 @@ class ClientEntityManager : Disposable { Math3D.VectorCopy(old.origin, newState.old_origin) if (update != null) { - newState.setByFlags(update.newState, update.header.flags) + update.delta.applyTo(newState) } // Cake: Decide if we should clear the cached model instance in certain cases. diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt index 0e5f22f4..2d236f6a 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt @@ -6,7 +6,9 @@ import jake2.qcommon.network.messages.server.PlayerInfoMessage import jake2.qcommon.network.messages.server.ServerMessage import jake2.qcommon.network.messages.server.SpawnBaselineMessage import jake2.qcommon.network.messages.server.EntityUpdate -import jake2.qcommon.network.messages.server.DeltaEntityHeader +import jake2.qcommon.network.messages.server.EntityDelta +import jake2.qcommon.network.messages.server.EntityStateSnapshot +import jake2.qcommon.Defines.* import jake2.qcommon.entity_state_t import jake2.qcommon.player_state_t import jake2.qcommon.sizebuf_t @@ -34,7 +36,7 @@ class ClientEntityManagerFrameSelectionTest { val buffer = sizebuf_t(queued.size).apply { queued.writeTo(this) } manager.processBaselineMessage(ServerMessage.parseFromBuffer(buffer) as SpawnBaselineMessage) manager.processServerFrameHeader(FrameHeaderMessage(1, -1, 0, 0, byteArrayOf())) - val updates = PacketEntitiesMessage().apply { this.updates.add(EntityUpdate(DeltaEntityHeader(0, 1), entity_state_t(1))) } + val updates = PacketEntitiesMessage(listOf(EntityUpdate.Change(EntityDelta(0, EntityStateSnapshot(1))))) assertTrue(manager.processPacketEntitiesMessage(updates)) assertArrayEquals(floatArrayOf(1.125f, 2.25f, 3.5f), manager.getEntityOrigin(1)!!.let { floatArrayOf(it.x, it.y, it.z) }) assertArrayEquals(floatArrayOf(45f, -90f, 0f), manager.getEntityAngles(1)) @@ -47,6 +49,45 @@ class ClientEntityManagerFrameSelectionTest { } } + @Test + fun packetChangesMergePreviousStatesAndBaselinesWhileClearingEventsAndRemovingEntities() { + CakeCvars.registerEntities() + val manager = ClientEntityManager() + fun state(index: Int) = entity_state_t(index).apply { + modelindex = index + origin[0] = index.toFloat() + event = 2 + } + fun receive(updates: List) { + val message = PacketEntitiesMessage(updates) + val buffer = sizebuf_t(message.size).apply { message.writeTo(this) } + assertTrue(manager.processPacketEntitiesMessage(ServerMessage.parseFromBuffer(buffer) as PacketEntitiesMessage)) + } + try { + val first = (1..3).map(::state) + manager.processBaselineMessage(SpawnBaselineMessage(state(4))) + manager.processServerFrameHeader(FrameHeaderMessage(1, -1, 0, 0, byteArrayOf())) + receive(first.map { requireNotNull(EntityUpdate.plan(entity_state_t(it.index), it, true, true)) }) + manager.processServerFrameHeader(FrameHeaderMessage(2, 1, 0, 0, byteArrayOf())) + receive(listOf( + // Explicit zeroes reset the old model/origin; entity 2 has no update and survives unchanged. + requireNotNull(EntityUpdate.plan(first[0], entity_state_t(1), false, false)), + EntityUpdate.Removal(U_REMOVE, 3), + // A newly visible entity inherits fields from its baseline. + EntityUpdate.Change(EntityDelta(0, EntityStateSnapshot(4))), + )) + val reconstructed = mutableListOf() + manager.forEachCurrentEntityState { reconstructed += it.getClone() } + assertEquals(listOf(1, 2, 4), reconstructed.map { it.index }) + assertEquals(listOf(0, 2, 4), reconstructed.map { it.modelindex }) + assertEquals(listOf(0f, 2f, 4f), reconstructed.map { it.origin[0] }) + assertEquals(listOf(1f, 2f, 4f), reconstructed.map { it.old_origin[0] }) + assertEquals(listOf(0, 0, 0), reconstructed.map { it.event }) + } finally { + manager.dispose() + } + } + @Test fun shorterAreaBitsClearThePreviousVisibilityTail() { CakeCvars.registerEntities() @@ -95,7 +136,7 @@ class ClientEntityManagerFrameSelectionTest { ), ), ) - assertTrue(manager.processPacketEntitiesMessage(PacketEntitiesMessage())) + assertTrue(manager.processPacketEntitiesMessage(PacketEntitiesMessage(emptyList()))) } private fun playerState(gunFrame: Int): player_state_t { diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerInterpolationGuardTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerInterpolationGuardTest.kt index 8a290146..85a8ceb8 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerInterpolationGuardTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerInterpolationGuardTest.kt @@ -2,7 +2,8 @@ package org.demoth.cake.stages.ingame import jake2.qcommon.Defines import jake2.qcommon.entity_state_t -import jake2.qcommon.network.messages.server.DeltaEntityHeader +import jake2.qcommon.network.messages.server.EntityDelta +import jake2.qcommon.network.messages.server.EntityStateSnapshot import jake2.qcommon.network.messages.server.EntityUpdate import jake2.qcommon.network.messages.server.FrameHeaderMessage import jake2.qcommon.network.messages.server.PacketEntitiesMessage @@ -24,27 +25,27 @@ class ClientEntityManagerInterpolationGuardTest { val teleportedOrigin = floatArrayOf(160f, 32f, 8f) manager.processServerFrameHeader(frameHeader(frameNumber = 1, lastFrame = 0)) - val firstFrameEntities = PacketEntitiesMessage().apply { - updates += update( + val firstFrameEntities = PacketEntitiesMessage(buildList { + add(update( number = entityNumber, flags = Defines.U_MODEL or Defines.U_ORIGIN1 or Defines.U_ORIGIN2 or Defines.U_ORIGIN3, ) { modelindex = 1 origin = initialOrigin.copyOf() - } - } + }) + }) manager.processPacketEntitiesMessage(firstFrameEntities) manager.processServerFrameHeader(frameHeader(frameNumber = 2, lastFrame = 1)) - val secondFrameEntities = PacketEntitiesMessage().apply { - updates += update( + val secondFrameEntities = PacketEntitiesMessage(buildList { + add(update( number = entityNumber, flags = Defines.U_EVENT or Defines.U_ORIGIN1 or Defines.U_ORIGIN2 or Defines.U_ORIGIN3, ) { event = Defines.EV_OTHER_TELEPORT origin = teleportedOrigin.copyOf() - } - } + }) + }) manager.processPacketEntitiesMessage(secondFrameEntities) val entity = getClientEntity(manager, entityNumber) @@ -61,7 +62,7 @@ class ClientEntityManagerInterpolationGuardTest { private fun update(number: Int, flags: Int, apply: entity_state_t.() -> Unit): EntityUpdate { val newState = entity_state_t(number).apply(apply) - return EntityUpdate(DeltaEntityHeader(flags, number), newState) + return EntityUpdate.Change(EntityDelta(flags, EntityStateSnapshot.fromEngine(newState))) } @Suppress("UNCHECKED_CAST") diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DeltaEntityHeader.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DeltaEntityHeader.java deleted file mode 100644 index a2b0f8f3..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DeltaEntityHeader.java +++ /dev/null @@ -1,37 +0,0 @@ -package jake2.qcommon.network.messages.server; - -public class DeltaEntityHeader { - public final int flags; - public final int number; - - public DeltaEntityHeader(int flags, int number) { - this.flags = flags; - this.number = number; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DeltaEntityHeader that = (DeltaEntityHeader) o; - - if (flags != that.flags) return false; - return number == that.number; - } - - @Override - public int hashCode() { - int result = flags; - result = 31 * result + number; - return result; - } - - @Override - public String toString() { - return "DeltaEntityHeader{" + - "flags=" + flags + - ", number=" + number + - '}'; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/EntityUpdate.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/EntityUpdate.java deleted file mode 100644 index dcb57aef..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/EntityUpdate.java +++ /dev/null @@ -1,69 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.entity_state_t; - -/** - * This class represents an update of a certain entity, calculated and transmitted from server to client on each frame. - */ -public class EntityUpdate { - public final DeltaEntityHeader header; - public final entity_state_t oldState; - public final entity_state_t newState; - public final boolean force; - public final boolean isNewEntity; - - // send an entity state change (delta of new & old states) - public EntityUpdate(entity_state_t oldState, entity_state_t newState, boolean force, boolean isNewEntity) { - this.oldState = oldState; - this.newState = newState; - this.force = force; - this.isNewEntity = isNewEntity; - // not required, will be calculated based on above parameters - this.header = null; - } - - // send an entity removed notification in the header (former entity bits) - public EntityUpdate(DeltaEntityHeader header) { - this.header = header; - - // not required - this.oldState = null; - this.newState = null; - this.force = false; - this.isNewEntity = false; - } - - // Receive the update from the server. - public EntityUpdate(DeltaEntityHeader header, entity_state_t newState) { - this.header = header; - this.newState = newState; - - // not required - this.oldState = null; - this.force = false; - this.isNewEntity = false; - } - - /** - * Compare headers if they exist in both objects - */ - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - EntityUpdate that = (EntityUpdate) o; - - if (header != null && that.header != null) - return header.equals(that.header); - else return newState != null ? newState.equals(that.newState) : that.newState == null; - } - - @Override - public String toString() { - return "EntityUpdate{" + - "header=" + header + - ", newState=" + newState + - '}'; - } -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java deleted file mode 100644 index e6dde4f0..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java +++ /dev/null @@ -1,102 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.*; -import jake2.qcommon.network.protocol.MessageReadException; -import jake2.qcommon.network.protocol.MessageRejection; - -import java.util.ArrayList; -import java.util.List; - -/** - * Contains state changes for multiple entities. - */ -public class PacketEntitiesMessage extends LegacyServerMessage { - - public final List updates = new ArrayList<>(); - - public PacketEntitiesMessage() { - super(ServerMessageType.svc_packetentities); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - for (EntityUpdate u : updates) { - // Outbound changes calculate their header from baseline/current state. - if (u.header == null) { - DeltaUtils.WriteDeltaEntity(u.oldState, u.newState, buffer, u.force, u.isNewEntity); - } else if ((u.header.flags & Defines.U_REMOVE) != 0) { - // Entity removal carries only a header (former entity bits). - DeltaUtils.writeEntityHeader(u.header.flags, u.header.number, buffer); - } else { - // Received changes already have a wire header; preserve its field presence and values. - DeltaUtils.writeEntityDelta(u.header.flags, u.header.number, u.newState, buffer); - } - } - // end of packetentities - buffer.writeShort(0); - } - - @Override - public void parse(sizebuf_t buffer) { - while (true) { - DeltaEntityHeader header = ServerMessage.parseDeltaEntityHeader(buffer); - // end of packetentities - if (0 == header.number) - break; - - if (header.number >= Defines.MAX_EDICTS) - throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "CL_ParsePacketEntities: bad number:" + header.number); - - if (buffer.readcount > buffer.cursize) - throw new MessageReadException(MessageRejection.TRUNCATED, buffer.readcount, "CL_ParsePacketEntities: end of message"); - - - if ((header.flags & Defines.U_REMOVE) != 0) { - updates.add(new EntityUpdate(header)); - } else { - entity_state_t newState = parseEntityState(header.number, header.flags, buffer); - updates.add(new EntityUpdate(header, newState)); - } - } - } - - @Override - public int getSize() { - return 1 + updates.stream().mapToInt(this::getUpdateSize).sum() + 2; - } - - private int getUpdateSize(EntityUpdate value) { - if (value.header == null) { - // Outbound change, including the forced unchanged-header case. - return DeltaUtils.getDeltaSize(value.oldState, value.newState, value.force, value.isNewEntity); - } else if ((value.header.flags & Defines.U_REMOVE) != 0) { - // Entity removal has no payload. - return DeltaUtils.getEntityHeaderSize(value.header.flags); - } else { - return DeltaUtils.getEntityDeltaSize(value.header.flags); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof PacketEntitiesMessage)) return false; - - PacketEntitiesMessage that = (PacketEntitiesMessage) o; - - return updates != null ? updates.equals(that.updates) : that.updates == null; - } - - @Override - public int hashCode() { - return updates != null ? updates.hashCode() : 0; - } - - @Override - public String toString() { - return "PacketEntitiesMessage{" + - "updates=" + updates + - '}'; - } -} - diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java index 9e21e3a7..0385f261 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ServerMessage.java @@ -1,7 +1,5 @@ package jake2.qcommon.network.messages.server; -import jake2.qcommon.Defines; -import jake2.qcommon.entity_state_t; import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; import jake2.qcommon.network.protocol.MessageReadException; @@ -17,115 +15,6 @@ public ServerMessage(ServerMessageType type) { this.type = type; } - /* - * CL_ParseEntityBits - * - * Returns the entity number (index) and the header flags - */ - protected static DeltaEntityHeader parseDeltaEntityHeader(sizebuf_t buffer) { - int flags = buffer.readByte(); - if ((flags & Defines.U_MOREBITS1) != 0) { - int b = buffer.readByte(); - flags |= b << 8; - } - if ((flags & Defines.U_MOREBITS2) != 0) { - int b = buffer.readByte(); - flags |= b << 16; - } - if ((flags & Defines.U_MOREBITS3) != 0) { - int b = buffer.readByte(); - flags |= b << 24; - } - - int number; - if ((flags & Defines.U_NUMBER16) != 0) - number = buffer.readShort(); - else - number = buffer.readByte(); - - validateEntityIndex(buffer, number); - return new DeltaEntityHeader(flags, number); - } - - /** Validate indices before a decoded message can address an engine entity table. */ - protected static void validateEntityIndex(sizebuf_t buffer, int number) { - if (number < 0 || number >= Defines.MAX_EDICTS) - throw new MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number " + number); - } - - /* - * former CL_ParseDelta - */ - protected static entity_state_t parseEntityState(int index, int flags, sizebuf_t buffer) { - entity_state_t to = new entity_state_t(index); - to.index = index; - - if ((flags & Defines.U_MODEL) != 0) - to.modelindex = buffer.readByte(); - if ((flags & Defines.U_MODEL2) != 0) - to.modelindex2 = buffer.readByte(); - if ((flags & Defines.U_MODEL3) != 0) - to.modelindex3 = buffer.readByte(); - if ((flags & Defines.U_MODEL4) != 0) - to.modelindex4 = buffer.readByte(); - - if ((flags & Defines.U_FRAME8) != 0) - to.frame = buffer.readByte(); - if ((flags & Defines.U_FRAME16) != 0) - to.frame = buffer.readShort(); - - // used for laser colors - if ((flags & Defines.U_SKIN8) != 0 && (flags & Defines.U_SKIN16) != 0) - to.skinnum = buffer.readInt(); - else if ((flags & Defines.U_SKIN8) != 0) - to.skinnum = buffer.readByte(); - else if ((flags & Defines.U_SKIN16) != 0) - to.skinnum = buffer.readShort(); - - if ((flags & (Defines.U_EFFECTS8 | Defines.U_EFFECTS16)) == (Defines.U_EFFECTS8 | Defines.U_EFFECTS16)) - to.effects = buffer.readInt(); - else if ((flags & Defines.U_EFFECTS8) != 0) - to.effects = buffer.readByte(); - else if ((flags & Defines.U_EFFECTS16) != 0) - to.effects = buffer.readShort(); - - if ((flags & (Defines.U_RENDERFX8 | Defines.U_RENDERFX16)) == (Defines.U_RENDERFX8 | Defines.U_RENDERFX16)) - to.renderfx = buffer.readInt(); - else if ((flags & Defines.U_RENDERFX8) != 0) - to.renderfx = buffer.readByte(); - else if ((flags & Defines.U_RENDERFX16) != 0) - to.renderfx = buffer.readShort(); - - if ((flags & Defines.U_ORIGIN1) != 0) - to.origin[0] = buffer.readCoord(); - if ((flags & Defines.U_ORIGIN2) != 0) - to.origin[1] = buffer.readCoord(); - if ((flags & Defines.U_ORIGIN3) != 0) - to.origin[2] = buffer.readCoord(); - - if ((flags & Defines.U_ANGLE1) != 0) - to.angles[0] = buffer.readAngleByte(); - if ((flags & Defines.U_ANGLE2) != 0) - to.angles[1] = buffer.readAngleByte(); - if ((flags & Defines.U_ANGLE3) != 0) - to.angles[2] = buffer.readAngleByte(); - - if ((flags & Defines.U_OLDORIGIN) != 0) - buffer.readPos(to.old_origin); - - if ((flags & Defines.U_SOUND) != 0) - to.sound = buffer.readByte(); - - if ((flags & Defines.U_EVENT) != 0) - to.event = buffer.readByte(); - else - to.event = 0; - - if ((flags & Defines.U_SOLID) != 0) - to.solid = buffer.readShort(); - return to; - } - public final void writeTo(sizebuf_t buffer) { ServerMessageCodec.write(this, buffer); } @@ -148,9 +37,6 @@ static LegacyServerMessage readLegacyProperties(ServerMessageType type, sizebuf_ case svc_playerinfo: msg = new PlayerInfoMessage(); break; - case svc_packetentities: - msg = new PacketEntitiesMessage(); - break; } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt index ddfe2050..1e49f1e8 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt @@ -90,11 +90,19 @@ object EntityDeltaCodec { fun write(delta: EntityDelta, buffer: sizebuf_t) { val flags = delta.flags val state = delta.state + writeHeader(flags, state.index, buffer) + writePayload(flags, state, buffer) + } + + internal fun writeHeader(flags: Int, number: Int, buffer: sizebuf_t) { buffer.writeByte(flags.toByte()) if (flags and U_MOREBITS1 != 0) buffer.writeByte((flags ushr 8).toByte()) if (flags and U_MOREBITS2 != 0) buffer.writeByte((flags ushr 16).toByte()) if (flags and U_MOREBITS3 != 0) buffer.writeByte((flags ushr 24).toByte()) - if (flags and U_NUMBER16 != 0) buffer.writeShort(state.index) else buffer.writeByte(state.index.toByte()) + if (flags and U_NUMBER16 != 0) buffer.writeShort(number) else buffer.writeByte(number.toByte()) + } + + private fun writePayload(flags: Int, state: EntityStateSnapshot, buffer: sizebuf_t) { if (flags and U_MODEL != 0) buffer.writeByte(state.modelIndex.toByte()) if (flags and U_MODEL2 != 0) buffer.writeByte(state.modelIndex2.toByte()) if (flags and U_MODEL3 != 0) buffer.writeByte(state.modelIndex3.toByte()) @@ -127,7 +135,10 @@ object EntityDeltaCodec { } /** CL_ParseEntityBits followed by the former CL_ParseDelta, retaining presence and selected widths. */ - fun read(buffer: MessageReader): EntityDelta { + fun read(buffer: MessageReader): EntityDelta = readPayload(readHeader(buffer), buffer) + + /** Validate indices before a decoded message can address an engine entity table. */ + internal fun readHeader(buffer: MessageReader): DeltaEntityHeader { var flags = buffer.readUnsignedByte() if (flags and U_MOREBITS1 != 0) flags = flags or (buffer.readUnsignedByte() shl 8) if (flags and U_MOREBITS2 != 0) flags = flags or (buffer.readUnsignedByte() shl 16) @@ -135,6 +146,12 @@ object EntityDeltaCodec { val number = if (flags and U_NUMBER16 != 0) buffer.readUnsignedShort() else buffer.readUnsignedByte() if (number >= MAX_EDICTS) throw MessageReadException(MessageRejection.INVALID_VALUE, buffer.readcount, "Invalid entity number $number") + return DeltaEntityHeader(flags, number) + } + + /** Former CL_ParseDelta: read selected fields after the entity header. */ + internal fun readPayload(header: DeltaEntityHeader, buffer: MessageReader): EntityDelta { + val (flags, number) = header val model = if (flags and U_MODEL != 0) buffer.readUnsignedByte() else 0 val model2 = if (flags and U_MODEL2 != 0) buffer.readUnsignedByte() else 0 val model3 = if (flags and U_MODEL3 != 0) buffer.readUnsignedByte() else 0 @@ -167,8 +184,7 @@ object EntityDeltaCodec { fun size(delta: EntityDelta): Int { val flags = delta.flags - var size = 1 + (if (flags and U_NUMBER16 != 0) 2 else 1) - size += Integer.bitCount(flags and (U_MOREBITS1 or U_MOREBITS2 or U_MOREBITS3)) + var size = headerSize(flags) size += Integer.bitCount(flags and (U_MODEL or U_MODEL2 or U_MODEL3 or U_MODEL4 or U_FRAME8 or U_SOUND or U_EVENT or U_ANGLE1 or U_ANGLE2 or U_ANGLE3)) size += 2 * Integer.bitCount(flags and (U_ORIGIN1 or U_ORIGIN2 or U_ORIGIN3 or U_FRAME16 or U_SOLID)) size += sizedLength(flags, U_SKIN8, U_SKIN16) + sizedLength(flags, U_EFFECTS8, U_EFFECTS16) + sizedLength(flags, U_RENDERFX8, U_RENDERFX16) @@ -176,6 +192,9 @@ object EntityDeltaCodec { return size } + internal fun headerSize(flags: Int): Int = 1 + (if (flags and U_NUMBER16 != 0) 2 else 1) + + Integer.bitCount(flags and (U_MOREBITS1 or U_MOREBITS2 or U_MOREBITS3)) + private fun sizedLength(flags: Int, byte: Int, short: Int): Int = when (flags and (byte or short)) { byte or short -> 4 byte -> 1 diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt index eded1d1e..f4f4dc14 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt @@ -1,7 +1,6 @@ package jake2.qcommon.network.messages.server -import jake2.qcommon.Defines.MAX_EDICTS -import jake2.qcommon.Defines.U_NUMBER16 +import jake2.qcommon.Defines.* import jake2.qcommon.entity_state_t import jake2.qcommon.math.Vector3f @@ -55,4 +54,31 @@ data class EntityStateSnapshot( /** Field presence and widths are part of the wire value; unselected fields are zero in planned and received states. */ data class EntityDelta(val flags: Int, val state: EntityStateSnapshot) { init { require(state.index < 256 || flags and U_NUMBER16 != 0) { "Entity number requires a 16-bit header" } } + + /** Copies selected fields into a reconstructed engine state, controlled by the serialization flags. */ + fun applyTo(target: entity_state_t) { + target.index = state.index + if (flags and U_ORIGIN1 != 0) target.origin[0] = state.origin.x + if (flags and U_ORIGIN2 != 0) target.origin[1] = state.origin.y + if (flags and U_ORIGIN3 != 0) target.origin[2] = state.origin.z + if (flags and U_ANGLE1 != 0) target.angles[0] = state.angles.x + if (flags and U_ANGLE2 != 0) target.angles[1] = state.angles.y + if (flags and U_ANGLE3 != 0) target.angles[2] = state.angles.z + if (flags and U_MODEL != 0) target.modelindex = state.modelIndex + if (flags and U_MODEL2 != 0) target.modelindex2 = state.modelIndex2 + if (flags and U_MODEL3 != 0) target.modelindex3 = state.modelIndex3 + if (flags and U_MODEL4 != 0) target.modelindex4 = state.modelIndex4 + if (flags and (U_FRAME8 or U_FRAME16) != 0) target.frame = state.frame + if (flags and (U_SKIN8 or U_SKIN16) != 0) target.skinnum = state.skinNumber + if (flags and (U_EFFECTS8 or U_EFFECTS16) != 0) target.effects = state.effects + if (flags and (U_RENDERFX8 or U_RENDERFX16) != 0) target.renderfx = state.renderEffects + if (flags and U_SOLID != 0) target.solid = state.solid + if (flags and U_SOUND != 0) target.sound = state.sound + if (flags and U_EVENT != 0) target.event = state.event + if (flags and U_OLDORIGIN != 0) { + target.old_origin[0] = state.oldOrigin.x + target.old_origin[1] = state.oldOrigin.y + target.old_origin[2] = state.oldOrigin.z + } + } } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesCodec.kt new file mode 100644 index 00000000..5c136247 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesCodec.kt @@ -0,0 +1,35 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.U_REMOVE +import jake2.qcommon.network.protocol.MessageReader +import jake2.qcommon.sizebuf_t + +/** Packet-entity framing: changes carry payloads, removals carry headers, entity zero ends the list. */ +internal object PacketEntitiesCodec { + fun write(message: PacketEntitiesMessage, buffer: sizebuf_t) { + for (update in message.updates) when (update) { + is EntityUpdate.Change -> EntityDeltaCodec.write(update.delta, buffer) + is EntityUpdate.Removal -> EntityDeltaCodec.writeHeader(update.flags, update.number, buffer) + } + // End of packetentities. + buffer.writeShort(0) + } + + fun read(buffer: MessageReader): PacketEntitiesMessage { + val updates = ArrayList() + while (true) { + val header = EntityDeltaCodec.readHeader(buffer) + // End of packetentities; no payload follows, regardless of field bits. + if (header.number == 0) return PacketEntitiesMessage(updates) + updates += if (header.flags and U_REMOVE != 0) EntityUpdate.Removal(header.flags, header.number) + else EntityUpdate.Change(EntityDeltaCodec.readPayload(header, buffer)) + } + } + + fun size(message: PacketEntitiesMessage): Int = 2 + message.updates.sumOf { update -> + when (update) { + is EntityUpdate.Change -> EntityDeltaCodec.size(update.delta) + is EntityUpdate.Removal -> EntityDeltaCodec.headerSize(update.flags) + } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt new file mode 100644 index 00000000..4ed3d5a7 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt @@ -0,0 +1,41 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +import jake2.qcommon.entity_state_t +import java.util.Collections + +/** Contains state changes for multiple entities, in server frame order. Owns its update list. */ +class PacketEntitiesMessage(updates: List) : ServerMessage(ServerMessageType.svc_packetentities) { + val updates: List = Collections.unmodifiableList(ArrayList(updates)) + override fun equals(other: Any?) = other is PacketEntitiesMessage && updates == other.updates + override fun hashCode() = updates.hashCode() + override fun toString() = "PacketEntitiesMessage(updates=$updates)" +} + +/** + * An update of one entity, calculated and transmitted from server to client on each frame. + * Value equality includes the header and payload; matching headers alone do not imply equal changes. + */ +sealed interface EntityUpdate { + val number: Int + /** A selected delta, either planned from baseline/previous state or received with its wire mask. */ + data class Change(val delta: EntityDelta) : EntityUpdate { + override val number: Int get() = delta.state.index + init { require(number != 0 && delta.flags and U_REMOVE == 0) } + } + /** Removal carries only the header (former entity bits), even if other field bits are set. */ + data class Removal(val flags: Int, override val number: Int) : EntityUpdate { + init { + require(number in 1 until MAX_EDICTS && flags and U_REMOVE != 0) + require(number < 256 || flags and U_NUMBER16 != 0) + } + } + companion object { + /** Snapshot and plan now: queued updates never retain mutable baseline/current engine state. */ + @JvmStatic fun plan(from: entity_state_t, to: entity_state_t, force: Boolean, newEntity: Boolean): Change? = + EntityDeltaCodec.plan(EntityStateSnapshot.fromEngine(from), EntityStateSnapshot.fromEngine(to), force, newEntity)?.let(::Change) + } +} + +/** CL_ParseEntityBits: entity number (index) and header flags, including received extension bits. */ +internal data class DeltaEntityHeader(val flags: Int, val number: Int) 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 880f5b0c..090e98af 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 @@ -37,6 +37,7 @@ object ServerMessageCodec { message.origin?.let { buffer.writeCoord(it.x); buffer.writeCoord(it.y); buffer.writeCoord(it.z) } } is TEMessage -> TemporaryEntityCodec.writeProperties(message, buffer) + is PacketEntitiesMessage -> PacketEntitiesCodec.write(message, buffer) is SpawnBaselineMessage -> EntityDeltaCodec.write(message.delta, buffer) is InventoryMessage -> repeat(MAX_ITEMS) { buffer.writeShort(message[it]) } is DownloadMessage.Chunk -> { @@ -78,6 +79,7 @@ object ServerMessageCodec { is DownloadMessage.Failure -> 4 is FrameHeaderMessage -> 11 + message.areaBits.size is TEMessage -> TemporaryEntityCodec.size(message) + is PacketEntitiesMessage -> 1 + PacketEntitiesCodec.size(message) is SpawnBaselineMessage -> 1 + EntityDeltaCodec.size(message.delta) DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 is LegacyServerMessage -> message.size @@ -110,6 +112,7 @@ object ServerMessageCodec { ServerMessageType.svc_muzzleflash2 -> MuzzleFlash2Message(readEntityIndex(buffer), buffer.readUnsignedByte()) ServerMessageType.svc_sound -> readSound(buffer) ServerMessageType.svc_temp_entity -> TemporaryEntityCodec.read(buffer) + ServerMessageType.svc_packetentities -> PacketEntitiesCodec.read(buffer) ServerMessageType.svc_spawnbaseline -> SpawnBaselineMessage(EntityDeltaCodec.read(buffer)) ServerMessageType.svc_inventory -> InventoryMessage(IntArray(MAX_ITEMS) { buffer.readSignedShort() }) ServerMessageType.svc_download -> readDownload(buffer) diff --git a/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java b/qcommon/src/test/java/jake2/qcommon/DeltaUtils.java similarity index 100% rename from qcommon/src/main/java/jake2/qcommon/DeltaUtils.java rename to qcommon/src/test/java/jake2/qcommon/DeltaUtils.java diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 11612063..79192cdb 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -115,17 +115,17 @@ public static Stream createTestData() { currentState.stats = new short[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1}; } testMessages.add(new Object[]{"server.PlayerInfoMessage", new PlayerInfoMessage(new player_state_t(), currentState)}); - PacketEntitiesMessage packetEntitiesMessage = new PacketEntitiesMessage(); + java.util.List updates = new java.util.ArrayList<>(); { - packetEntitiesMessage.updates.add(new EntityUpdate(new DeltaEntityHeader(U_REMOVE, 32))); + updates.add(new EntityUpdate.Removal(U_REMOVE, 32)); entity_state_t newState = new entity_state_t(1); { newState.modelindex = 3; newState.origin = new float[]{2, 3, 4}; } - packetEntitiesMessage.updates.add(new EntityUpdate(new entity_state_t(1), newState, false, true)); + updates.add(EntityUpdate.plan(new entity_state_t(1), newState, false, true)); } - testMessages.add(new Object[]{"server.PacketEntitiesMessage", packetEntitiesMessage}); + testMessages.add(new Object[]{"server.PacketEntitiesMessage", new PacketEntitiesMessage(updates)}); ////////////////// // CLIENT MESSAGES ////////////////// diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt index 93280d6e..111a6835 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt @@ -30,9 +30,10 @@ class MessageReencodingTest { fun `forced unchanged updates fit their declared size across entity number widths`() { for (index in listOf(1, 255, 256, MAX_EDICTS - 1)) { for (force in listOf(false, true)) for (isNew in listOf(false, true)) { - val message = PacketEntitiesMessage().apply { - updates.add(EntityUpdate(entity_state_t(index), entity_state_t(index), force, isNew)) + val entries = mutableListOf().apply { + EntityUpdate.plan(entity_state_t(index), entity_state_t(index), force, isNew)?.let(::add) } + val message = PacketEntitiesMessage(entries) if (index < 256 && !isNew) assertEquals(if (force) 5 else 3, message.size) roundTrip(message) } @@ -54,27 +55,29 @@ class MessageReencodingTest { ) for (index in listOf(1, 256, MAX_EDICTS - 1)) for (change in changes) { val current = entity_state_t(index).also(change) - val message = PacketEntitiesMessage().apply { - updates.add(EntityUpdate(entity_state_t(index), current, false, true)) + val entries = mutableListOf().apply { + add(requireNotNull(EntityUpdate.plan(entity_state_t(index), current, false, true))) val flags = U_REMOVE or if (index >= 256) U_NUMBER16 or U_MOREBITS1 else 0 - updates.add(EntityUpdate(DeltaEntityHeader(flags, index))) + add(EntityUpdate.Removal(flags, index)) } + val message = PacketEntitiesMessage(entries) roundTrip(message) val parsed = parse(write(message)) as PacketEntitiesMessage - assertNotNull(parsed.updates[0].newState) - assertNull(parsed.updates[1].newState) + assertInstanceOf(EntityUpdate.Change::class.java, parsed.updates[0]) + assertInstanceOf(EntityUpdate.Removal::class.java, parsed.updates[1]) } } @Test fun `explicit zero fields and zero-valued flag extensions survive re-encoding`() { val old = entity_state_t(1).apply { modelindex = 5; origin[0] = 10f } - val message = PacketEntitiesMessage().apply { - updates.add(EntityUpdate(old, entity_state_t(1), false, false)) + val entries = mutableListOf().apply { + add(requireNotNull(EntityUpdate.plan(old, entity_state_t(1), false, false))) } + val message = PacketEntitiesMessage(entries) val decoded = parse(write(message)) as PacketEntitiesMessage - assertTrue(decoded.updates.single().header.flags and U_MODEL != 0) - assertEquals(0, decoded.updates.single().newState.modelindex) + assertTrue((decoded.updates.single() as EntityUpdate.Change).delta.flags and U_MODEL != 0) + assertEquals(0, (decoded.updates.single() as EntityUpdate.Change).delta.state.modelIndex) roundTrip(message) for (bytes in listOf( byteArrayOf(18, 128.toByte(), 0, 1, 0, 0), diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketEntitiesCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketEntitiesCodecTest.kt new file mode 100644 index 00000000..b98441cc --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketEntitiesCodecTest.kt @@ -0,0 +1,83 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import jake2.qcommon.network.transport.PacketBytes +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PacketEntitiesCodecTest { + private fun write(message: PacketEntitiesMessage): ByteArray = sizebuf_t(message.size).apply { + message.writeTo(this) + assertEquals(message.size, cursize) + }.let { it.data.copyOf(it.cursize) } + + private fun read(bytes: ByteArray): PacketEntitiesMessage = MessageReader(PacketBytes.copyOf(bytes), 0).let { + val message = ServerMessage.parseFromBuffer(it) as PacketEntitiesMessage + assertEquals(bytes.size, it.readcount) + message + } + + @Test + fun `queued changes own engine states and the update list and compare payloads`() { + val previous = entity_state_t(1).apply { modelindex = 3; origin[0] = 1f } + val current = entity_state_t(1).apply { modelindex = 7; origin[0] = 2.125f } + val entries = arrayListOf(requireNotNull(EntityUpdate.plan(previous, current, false, false))) + val message = PacketEntitiesMessage(entries) + val bytes = write(message) + val hash = message.hashCode() + previous.clear(); current.clear(); entries.clear() + assertArrayEquals(bytes, write(message)) + assertEquals(hash, message.hashCode()) + assertEquals(message, read(bytes)) + assertThrows(UnsupportedOperationException::class.java) { (message.updates as MutableList).clear() } + val change = message.updates.single() as EntityUpdate.Change + val differentPayload = PacketEntitiesMessage(listOf(change.copy(delta = change.delta.copy(state = change.delta.state.copy(modelIndex = 8))))) + assertNotEquals(message, differentPayload, "Equal headers must not hide different payloads") + } + + @Test + fun `removals preserve extended headers without consuming selected payload bits`() { + for (index in listOf(1, 255, 256, MAX_EDICTS - 1)) { + val flags = U_REMOVE or U_MODEL or U_ORIGIN1 or U_MOREBITS1 or U_MOREBITS2 or U_MOREBITS3 or + (if (index >= 256) U_NUMBER16 else 0) + val bytes = sizebuf_t(32).apply { + writeByte(18) + jake2.qcommon.DeltaUtils.writeEntityHeader(flags, index, this) + writeShort(0) + }.let { it.data.copyOf(it.cursize) } + assertEquals(EntityUpdate.Removal(flags, index), read(bytes).updates.single()) + assertArrayEquals(bytes, write(read(bytes))) + } + // Entity zero ends the list before fields are read, even with a model bit set. + assertTrue(read(byteArrayOf(18, 128.toByte(), 8, 0)).updates.isEmpty()) + } + + @Test + fun `invalid indices and truncated headers or payloads reject the entire body`() { + val valid = write(PacketEntitiesMessage(listOf(requireNotNull(EntityUpdate.plan(entity_state_t(256), + entity_state_t(256).apply { modelindex = 4; origin[2] = 7f }, true, true))))) + for (length in 1 until valid.size) { + val result = decodeBody(valid.copyOf(length)) + assertEquals(MessageRejection.TRUNCATED, (result as MessageDecodeResult.Rejected).reason) + } + for (index in listOf(MAX_EDICTS, 65535)) { + val bytes = sizebuf_t(16).apply { + writeByte(18) + jake2.qcommon.DeltaUtils.writeEntityHeader(U_MOREBITS1 or U_NUMBER16 or U_REMOVE, index, this) + writeShort(0) + }.let { it.data.copyOf(it.cursize) } + assertEquals(MessageRejection.INVALID_VALUE, (decodeBody(bytes) as MessageDecodeResult.Rejected).reason) + } + } + + private fun decodeBody(bytes: ByteArray): MessageDecodeResult { + // Prefix a valid message to check that malformed packetentities cannot deliver a partial body. + val body = PacketBytes.copyOf(byteArrayOf(7) + bytes) + val wire = PacketCodec.encodeSequenced(1, 0, null, body) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + return MessageBodyCodec.decodeServer(envelope) + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 723b58b9..647f8b61 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -37,7 +37,6 @@ import jake2.qcommon.network.messages.server.PlayerInfoMessage; import jake2.qcommon.network.messages.server.PacketEntitiesMessage; import jake2.qcommon.network.messages.server.EntityUpdate; -import jake2.qcommon.network.messages.server.DeltaEntityHeader; import jake2.qcommon.network.messages.server.ServerMessage; import jake2.qcommon.network.messages.server.StuffTextMessage; import jake2.qcommon.network.messages.server.SoundMessage; @@ -1411,7 +1410,7 @@ private PacketEntitiesMessage buildPacketEntities(client_frame_t lastReceivedFra int maxClients = serverMain.getClients().size(); assert baselines.length == Defines.MAX_EDICTS; int maxEntityStates = clientEntityStates.length; - PacketEntitiesMessage result = new PacketEntitiesMessage(); + List updates = new ArrayList<>(); // Write delta entity int from_num_entities; @@ -1449,12 +1448,13 @@ private PacketEntitiesMessage buildPacketEntities(client_frame_t lastReceivedFra // Note: players are always 'newentities', this updates // their oldorigin always and prevents warping final boolean isPlayer = newState.index <= maxClients; - result.updates.add(new EntityUpdate(oldState, newState, false, isPlayer)); + EntityUpdate.Change change = EntityUpdate.plan(oldState, newState, false, isPlayer); + if (change != null) updates.add(change); oldindex++; newindex++; } else if (newnum < oldnum) { // this is a new entity, send it from the baseline - result.updates.add(new EntityUpdate(baselines[newnum], newState, true, true)); + updates.add(EntityUpdate.plan(baselines[newnum], newState, true, true)); newindex++; } else { // if (newnum > oldnum) { @@ -1464,11 +1464,11 @@ private PacketEntitiesMessage buildPacketEntities(client_frame_t lastReceivedFra if (oldnum >= 256) flags |= Defines.U_NUMBER16 | Defines.U_MOREBITS1; - result.updates.add(new EntityUpdate(new DeltaEntityHeader(flags, oldnum))); + updates.add(new EntityUpdate.Removal(flags, oldnum)); oldindex++; } } - return result; + return new PacketEntitiesMessage(updates); } /** From b90541eac1c317ff84bcf9f2164ea9212c6057ee Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Wed, 28 Oct 2026 23:52:55 +0100 Subject: [PATCH 47/57] Make player state deltas immutable Kotlin values --- .../cake/stages/ingame/ClientEntityManager.kt | 50 +------ .../ClientEntityManagerFrameSelectionTest.kt | 54 ++++++- .../messages/server/LegacyServerMessage.java | 25 ---- .../messages/server/ServerMessage.java | 19 --- .../messages/server/PlayerInfoMessage.kt | 140 ++++++++++++++++++ .../messages/server/PlayerStateCodec.kt | 113 ++++++++++++++ .../messages/server/ServerMessageCodec.kt | 11 +- .../messages/NetworkMessageSizeTest.java | 2 +- .../server/LegacyPlayerInfoReference.java} | 22 +-- .../network/protocol/PlayerStateCodecTest.kt | 135 +++++++++++++++++ .../java/jake2/server/GameImportsImpl.java | 2 +- 11 files changed, 462 insertions(+), 111 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/messages/server/LegacyServerMessage.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerInfoMessage.kt create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt rename qcommon/src/{main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java => test/java/jake2/qcommon/network/messages/server/LegacyPlayerInfoReference.java} (95%) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt 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 664f0f1c..4ed0423a 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 @@ -746,56 +746,10 @@ class ClientEntityManager : Disposable { currentPlayerState.set(deltaFrame.playerstate) } - // - // parse the pmove_state_t - // - if ((msg.deltaFlags and Defines.PS_M_TYPE) != 0) - currentPlayerState.pmove.pm_type = msg.currentState.pmove.pm_type; + msg.applyTo(currentPlayerState) // if (ClientGlobals.cl.attractloop) -// state.pmove.pm_type = Defines.PM_FREEZE; // demo playback - - if ((msg.deltaFlags and Defines.PS_M_ORIGIN) != 0) - currentPlayerState.pmove.origin = msg.currentState.pmove.origin; - if ((msg.deltaFlags and Defines.PS_M_VELOCITY) != 0) - currentPlayerState.pmove.velocity = msg.currentState.pmove.velocity; - if ((msg.deltaFlags and Defines.PS_M_TIME) != 0) - currentPlayerState.pmove.pm_time = msg.currentState.pmove.pm_time; - if ((msg.deltaFlags and Defines.PS_M_FLAGS) != 0) - currentPlayerState.pmove.pm_flags = msg.currentState.pmove.pm_flags; - if ((msg.deltaFlags and Defines.PS_M_GRAVITY) != 0) - currentPlayerState.pmove.gravity = msg.currentState.pmove.gravity; - if ((msg.deltaFlags and Defines.PS_M_DELTA_ANGLES) != 0) - currentPlayerState.pmove.delta_angles = msg.currentState.pmove.delta_angles; - // - // parse the rest of the player_state_t - // - if ((msg.deltaFlags and Defines.PS_VIEWOFFSET) != 0) - currentPlayerState.viewoffset = msg.currentState.viewoffset; - if ((msg.deltaFlags and Defines.PS_VIEWANGLES) != 0) - currentPlayerState.viewangles = msg.currentState.viewangles; - if ((msg.deltaFlags and Defines.PS_KICKANGLES) != 0) - currentPlayerState.kick_angles = msg.currentState.kick_angles; - if ((msg.deltaFlags and Defines.PS_WEAPONINDEX) != 0) - currentPlayerState.gunindex = msg.currentState.gunindex; - if ((msg.deltaFlags and Defines.PS_WEAPONFRAME) != 0) { - currentPlayerState.gunframe = msg.currentState.gunframe; - currentPlayerState.gunoffset = msg.currentState.gunoffset; - currentPlayerState.gunangles = msg.currentState.gunangles; - } - if ((msg.deltaFlags and Defines.PS_BLEND) != 0) - currentPlayerState.blend = msg.currentState.blend; - if ((msg.deltaFlags and Defines.PS_FOV) != 0) - currentPlayerState.fov = msg.currentState.fov; - if ((msg.deltaFlags and Defines.PS_RDFLAGS) != 0) - currentPlayerState.rdflags = msg.currentState.rdflags; - - // copy only changed stats - for (i in (0.. = emptyMap()) { + private val values: Map = Collections.unmodifiableMap(HashMap(values)) + val mask: Int = this.values.keys.fold(0) { mask, index -> mask or (1 shl index) } + init { require(this.values.all { (index, value) -> index in 0 until MAX_STATS && value in -32768..32767 }) } + operator fun get(index: Int): Int? = values[index] + override fun equals(other: Any?) = other is PlayerStatDelta && values == other.values + override fun hashCode() = values.hashCode() + override fun toString() = values.toString() +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt new file mode 100644 index 00000000..557de698 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt @@ -0,0 +1,113 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +import jake2.qcommon.player_state_t +import jake2.qcommon.sizebuf_t +import jake2.qcommon.util.Math3D +import jake2.qcommon.network.protocol.MessageReader + +/** Player delta selection is separate from encoding an already selected immutable wire value. */ +internal object PlayerStateCodec { + fun plan(previous: player_state_t, current: player_state_t): PlayerInfoMessage = PlayerInfoMessage( + movementType = if (current.pmove.pm_type != previous.pmove.pm_type) current.pmove.pm_type and 255 else null, + origin = if (different(current.pmove.origin, previous.pmove.origin)) shorts(current.pmove.origin) else null, + velocity = if (different(current.pmove.velocity, previous.pmove.velocity)) shorts(current.pmove.velocity) else null, + movementTime = if (current.pmove.pm_time != previous.pmove.pm_time) current.pmove.pm_time.toInt() and 255 else null, + movementFlags = if (current.pmove.pm_flags != previous.pmove.pm_flags) current.pmove.pm_flags.toInt() and 255 else null, + gravity = if (current.pmove.gravity != previous.pmove.gravity) current.pmove.gravity.toInt() else null, + deltaAngles = if (different(current.pmove.delta_angles, previous.pmove.delta_angles)) shorts(current.pmove.delta_angles) else null, + viewOffset = if (different(current.viewoffset, previous.viewoffset)) quarterBytes(current.viewoffset) else null, + viewAngles = if (different(current.viewangles, previous.viewangles)) angleShorts(current.viewangles) else null, + kickAngles = if (different(current.kick_angles, previous.kick_angles)) quarterBytes(current.kick_angles) else null, + // Always sent, even if unchanged. + weaponIndex = current.gunindex and 255, + // Gun offsets/angles are sent only when gunframe changes, preserving protocol-34 behavior. + weaponFrame = if (current.gunframe != previous.gunframe) PlayerWeaponFrame(current.gunframe and 255, quarterBytes(current.gunoffset), quarterBytes(current.gunangles)) else null, + blend = if (different(current.blend, previous.blend, 4)) PlayerBlend((current.blend[0] * 255).toInt() and 255, (current.blend[1] * 255).toInt() and 255, (current.blend[2] * 255).toInt() and 255, (current.blend[3] * 255).toInt() and 255) else null, + fov = if (current.fov != previous.fov) current.fov.toInt() and 255 else null, + renderFlags = if (current.rdflags != previous.rdflags) current.rdflags and 255 else null, + stats = PlayerStatDelta((0 until MAX_STATS).filter { current.stats[it] != previous.stats[it] } + .associateWith { current.stats[it].toInt() }), + ) + + // Compare before quantization, just as the legacy writer did (including sub-unit changes). + private fun different(a: FloatArray, b: FloatArray, count: Int = 3): Boolean = (0 until count).any { a[it] != b[it] } + private fun different(a: ShortArray, b: ShortArray): Boolean = (0 until 3).any { a[it] != b[it] } + private fun shorts(v: ShortArray) = PlayerDeltaVector(v[0].toInt(), v[1].toInt(), v[2].toInt()) + private fun angleShorts(v: FloatArray) = PlayerDeltaVector( + Math3D.ANGLE2SHORT(v[0]).toShort().toInt(), Math3D.ANGLE2SHORT(v[1]).toShort().toInt(), Math3D.ANGLE2SHORT(v[2]).toShort().toInt()) + private fun quarterBytes(v: FloatArray) = PlayerDeltaVector( + (v[0] * 4).toInt().toByte().toInt(), (v[1] * 4).toInt().toByte().toInt(), (v[2] * 4).toInt().toByte().toInt()) + + fun write(message: PlayerInfoMessage, buffer: sizebuf_t) { + buffer.writeShort(message.deltaFlags) + // Write the pmove_state_t, then the rest of the player_state_t. + message.movementType?.let { buffer.writeByte(it.toByte()) } + message.origin?.let { writeVector(it, buffer, true) } + message.velocity?.let { writeVector(it, buffer, true) } + message.movementTime?.let { buffer.writeByte(it.toByte()) } + message.movementFlags?.let { buffer.writeByte(it.toByte()) } + message.gravity?.let { buffer.writeShort(it) } + message.deltaAngles?.let { writeVector(it, buffer, true) } + message.viewOffset?.let { writeVector(it, buffer, false) } + message.viewAngles?.let { writeVector(it, buffer, true) } + message.kickAngles?.let { writeVector(it, buffer, false) } + message.weaponIndex?.let { buffer.writeByte(it.toByte()) } + message.weaponFrame?.let { buffer.writeByte(it.frame.toByte()); writeVector(it.offset, buffer, false); writeVector(it.angles, buffer, false) } + message.blend?.let { buffer.writeByte(it.red.toByte()); buffer.writeByte(it.green.toByte()); buffer.writeByte(it.blue.toByte()); buffer.writeByte(it.alpha.toByte()) } + message.fov?.let { buffer.writeByte(it.toByte()) } + message.renderFlags?.let { buffer.writeByte(it.toByte()) } + buffer.writeInt(message.stats.mask) + for (i in 0 until MAX_STATS) message.stats[i]?.let { buffer.writeShort(it) } + } + + private fun writeVector(v: PlayerDeltaVector, buffer: sizebuf_t, short: Boolean) { + if (short) { buffer.writeShort(v.x); buffer.writeShort(v.y); buffer.writeShort(v.z) } + else { buffer.writeByte(v.x.toByte()); buffer.writeByte(v.y.toByte()); buffer.writeByte(v.z.toByte()) } + } + + fun read(buffer: MessageReader): PlayerInfoMessage { + val flags = buffer.readUnsignedShort() + val movementType = if (flags and PS_M_TYPE != 0) buffer.readUnsignedByte() else null + val origin = if (flags and PS_M_ORIGIN != 0) readVector(buffer, true) else null + val velocity = if (flags and PS_M_VELOCITY != 0) readVector(buffer, true) else null + val movementTime = if (flags and PS_M_TIME != 0) buffer.readUnsignedByte() else null + val movementFlags = if (flags and PS_M_FLAGS != 0) buffer.readUnsignedByte() else null + val gravity = if (flags and PS_M_GRAVITY != 0) buffer.readSignedShort() else null + val deltaAngles = if (flags and PS_M_DELTA_ANGLES != 0) readVector(buffer, true) else null + val viewOffset = if (flags and PS_VIEWOFFSET != 0) readVector(buffer, false) else null + val viewAngles = if (flags and PS_VIEWANGLES != 0) readVector(buffer, true) else null + val kickAngles = if (flags and PS_KICKANGLES != 0) readVector(buffer, false) else null + val weaponIndex = if (flags and PS_WEAPONINDEX != 0) buffer.readUnsignedByte() else null + val weaponFrame = if (flags and PS_WEAPONFRAME != 0) PlayerWeaponFrame(buffer.readUnsignedByte(), readVector(buffer, false), readVector(buffer, false)) else null + val blend = if (flags and PS_BLEND != 0) PlayerBlend(buffer.readUnsignedByte(), buffer.readUnsignedByte(), buffer.readUnsignedByte(), buffer.readUnsignedByte()) else null + val fov = if (flags and PS_FOV != 0) buffer.readUnsignedByte() else null + val renderFlags = if (flags and PS_RDFLAGS != 0) buffer.readUnsignedByte() else null + // Parse only selected stats; signed mask bit 31 is a valid stat slot. + val statMask = buffer.readInt() + val stats = PlayerStatDelta((0 until MAX_STATS).filter { statMask and (1 shl it) != 0 } + .associateWith { buffer.readSignedShort() }) + return PlayerInfoMessage(movementType, origin, velocity, movementTime, movementFlags, gravity, deltaAngles, viewOffset, viewAngles, kickAngles, weaponIndex, weaponFrame, blend, fov, renderFlags, stats, flags and 0x8000) + } + + private fun readVector(buffer: MessageReader, short: Boolean): PlayerDeltaVector = + if (short) PlayerDeltaVector(buffer.readSignedShort(), buffer.readSignedShort(), buffer.readSignedShort()) + else PlayerDeltaVector(buffer.readSignedByte().toInt(), buffer.readSignedByte().toInt(), buffer.readSignedByte().toInt()) + + fun size(message: PlayerInfoMessage): Int = 6 + 2 * Integer.bitCount(message.stats.mask) + + (if (message.movementType != null) 1 else 0) + + (if (message.origin != null) 6 else 0) + + (if (message.velocity != null) 6 else 0) + + (if (message.movementTime != null) 1 else 0) + + (if (message.movementFlags != null) 1 else 0) + + (if (message.gravity != null) 2 else 0) + + (if (message.deltaAngles != null) 6 else 0) + + (if (message.viewOffset != null) 3 else 0) + + (if (message.viewAngles != null) 6 else 0) + + (if (message.kickAngles != null) 3 else 0) + + (if (message.weaponIndex != null) 1 else 0) + + (if (message.weaponFrame != null) 7 else 0) + + (if (message.blend != null) 4 else 0) + + (if (message.fov != null) 1 else 0) + + (if (message.renderFlags != null) 1 else 0) +} 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 090e98af..92d81359 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 @@ -6,7 +6,7 @@ import jake2.qcommon.network.protocol.MessageReader import jake2.qcommon.network.protocol.MessageRejection import jake2.qcommon.sizebuf_t -/** Server wire operations. Mutable array/delta families retain their existing codecs during migration. */ +/** Server wire operations construct and encode immutable message values. */ object ServerMessageCodec { @JvmStatic fun write(message: ServerMessage, buffer: sizebuf_t) { @@ -56,7 +56,7 @@ object ServerMessageCodec { buffer.writeBytes(message.areaBits.toByteArray()) } DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> Unit - is LegacyServerMessage -> message.writeLegacyProperties(buffer) + is PlayerInfoMessage -> PlayerStateCodec.write(message, buffer) else -> error("Unsupported server message ${message.javaClass.name}") } } @@ -82,7 +82,7 @@ object ServerMessageCodec { is PacketEntitiesMessage -> 1 + PacketEntitiesCodec.size(message) is SpawnBaselineMessage -> 1 + EntityDeltaCodec.size(message.delta) DisconnectMessage, ReconnectMessage, NopMessage, EndOfServerPacketMessage -> 1 - is LegacyServerMessage -> message.size + is PlayerInfoMessage -> 1 + PlayerStateCodec.size(message) else -> error("Unsupported server message ${message.javaClass.name}") } @@ -95,7 +95,7 @@ object ServerMessageCodec { } if (buffer.readcount == buffer.cursize) return EndOfServerPacketMessage val opcode = buffer.readUnsignedByte() - return when (val type = ServerMessageType.fromInt(opcode)) { + return when (ServerMessageType.fromInt(opcode)) { ServerMessageType.svc_end -> EndOfServerPacketMessage ServerMessageType.svc_nop -> null ServerMessageType.svc_disconnect -> DisconnectMessage @@ -125,7 +125,8 @@ object ServerMessageCodec { } null, ServerMessageType.svc_bad -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode $opcode") - else -> ServerMessage.readLegacyProperties(type, buffer) + ServerMessageType.svc_playerinfo -> PlayerStateCodec.read(buffer) + else -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode $opcode") } } diff --git a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java index 79192cdb..bdd0e3f1 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -114,7 +114,7 @@ public static Stream createTestData() { currentState.gunoffset = new float[]{6, 4, 2}; currentState.stats = new short[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1}; } - testMessages.add(new Object[]{"server.PlayerInfoMessage", new PlayerInfoMessage(new player_state_t(), currentState)}); + testMessages.add(new Object[]{"server.PlayerInfoMessage", PlayerInfoMessage.fromGame(new player_state_t(), currentState)}); java.util.List updates = new java.util.ArrayList<>(); { updates.add(new EntityUpdate.Removal(U_REMOVE, 32)); diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java b/qcommon/src/test/java/jake2/qcommon/network/messages/server/LegacyPlayerInfoReference.java similarity index 95% rename from qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java rename to qcommon/src/test/java/jake2/qcommon/network/messages/server/LegacyPlayerInfoReference.java index c62a39fd..85d025a7 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PlayerInfoMessage.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/server/LegacyPlayerInfoReference.java @@ -4,23 +4,27 @@ import jake2.qcommon.player_state_t; import jake2.qcommon.sizebuf_t; -public class PlayerInfoMessage extends LegacyServerMessage { +/** Pre-migration protocol-34 writer/parser retained only as an independent compatibility oracle. */ +public class LegacyPlayerInfoReference implements jake2.qcommon.network.messages.NetworkMessage { private player_state_t previousState; // determine what needs to be sent public int deltaFlags; public int statbits; public player_state_t currentState; - public PlayerInfoMessage() { - super(ServerMessageType.svc_playerinfo); - } + public LegacyPlayerInfoReference() {} - public PlayerInfoMessage(player_state_t previousState, player_state_t currentState) { + public LegacyPlayerInfoReference(player_state_t previousState, player_state_t currentState) { this(); this.previousState = previousState; this.currentState = currentState; } + public void writeTo(sizebuf_t buffer) { + buffer.writeByte((byte) 17); + writeProperties(buffer); + } + // determine what needs to be sent private void computeDeltaFlags() { if (currentState.pmove.pm_type != previousState.pmove.pm_type) @@ -88,7 +92,6 @@ private void computeDeltaFlags() { } } - @Override protected void writeProperties(sizebuf_t buffer) { computeDeltaFlags(); buffer.writeShort(deltaFlags); @@ -176,7 +179,6 @@ protected void writeProperties(sizebuf_t buffer) { } } - @Override public void parse(sizebuf_t buffer) { this.deltaFlags = buffer.readShort(); this.currentState = new player_state_t(); @@ -339,7 +341,7 @@ public int getSize() { @Override public String toString() { - return "PlayerInfoMessage{" + + return "LegacyPlayerInfoReference{" + "currentState=" + currentState + '}'; } @@ -347,9 +349,9 @@ public String toString() { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof PlayerInfoMessage)) return false; + if (!(o instanceof LegacyPlayerInfoReference)) return false; - PlayerInfoMessage that = (PlayerInfoMessage) o; + LegacyPlayerInfoReference that = (LegacyPlayerInfoReference) o; return currentState != null ? currentState.equals(that.currentState) : that.currentState == null; } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt new file mode 100644 index 00000000..db6c9d53 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt @@ -0,0 +1,135 @@ +package jake2.qcommon.network.protocol + +import jake2.qcommon.Defines.* +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.player_state_t +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PlayerStateCodecTest { + private fun write(message: NetworkMessage): ByteArray = sizebuf_t(message.size).apply { + message.writeTo(this) + assertEquals(message.size, cursize) + }.let { it.data.copyOf(it.cursize) } + + private fun read(bytes: ByteArray): PlayerInfoMessage { + val buffer = MessageReader(PacketBytes.copyOf(bytes), 0) + val message = ServerMessage.parseFromBuffer(buffer) as PlayerInfoMessage + assertEquals(bytes.size, buffer.readcount) + buffer.data.fill(0) + return message + } + + @Test + fun `all field masks match legacy decoding and retain raw payloads on reencoding`() { + for (mask in 0..65535) { + // The old parser independently determines payload order, signedness, and consumed length. + val raw = sizebuf_t(256).apply { + writeByte(17); writeShort(mask) + repeat(253) { writeByte((it * 73 + 129).toByte()) } + readcount = 1 + } + val legacy = LegacyPlayerInfoReference().apply { parse(raw) } + val bytes = raw.data.copyOf(raw.readcount) + val message = read(bytes) + assertEquals(mask, message.deltaFlags) + assertArrayEquals(bytes, write(message), "mask=$mask") + assertEquals(legacy.currentState, player_state_t().also(message::applyTo), "mask=$mask") + } + } + + @Test + fun `outbound selection and quantization match the old writer at boundaries`() { + val changes: List<(player_state_t, Int) -> Unit> = listOf( + { s, v -> s.pmove.pm_type = v }, + { s, v -> s.pmove.origin[0] = v.toShort(); s.pmove.origin[2] = (-v).toShort() }, + { s, v -> s.pmove.velocity[1] = v.toShort() }, + { s, v -> s.pmove.pm_time = v.toByte(); s.pmove.pm_flags = v.toByte(); s.pmove.gravity = v.toShort() }, + { s, v -> s.pmove.delta_angles[0] = v.toShort() }, + { s, v -> s.viewoffset[0] = v / 8f; s.kick_angles[1] = -v / 8f }, + { s, v -> s.viewangles = floatArrayOf(v.toFloat(), -v.toFloat(), v / 1000f) }, + { s, v -> s.gunindex = v; s.gunframe = v; s.gunoffset[1] = v / 8f; s.gunangles[2] = -v / 8f }, + { s, v -> s.blend = floatArrayOf(v / 255f, v / 1000f, -v / 255f, 1f); s.fov = v + 0.5f; s.rdflags = v }, + { s, v -> for (i in 0 until MAX_STATS) s.stats[i] = (v + i).toShort() }, + ) + for (value in listOf(Int.MIN_VALUE, -65536, -32768, -257, -128, -1, 0, 1, 127, 128, 255, 256, 32767, 65535, Int.MAX_VALUE)) { + for (change in changes) { + val current = player_state_t().also { change(it, value) } + val message = PlayerInfoMessage.fromGame(player_state_t(), current) + assertArrayEquals(write(LegacyPlayerInfoReference(player_state_t(), current)), write(message)) + assertEquals(message, read(write(message))) + } + } + // A sub-unit change must retain presence even when its encoded value is zero. + val tiny = PlayerInfoMessage.fromGame(player_state_t(), player_state_t().apply { viewoffset[0] = 0.01f }) + assertEquals(PlayerDeltaVector(0, 0, 0), tiny.viewOffset) + // Weapon offset/angle changes alone never add the weapon-frame group. + val offsetsOnly = PlayerInfoMessage.fromGame(player_state_t(), player_state_t().apply { gunoffset[0] = 2f; gunangles[0] = 3f }) + assertNull(offsetsOnly.weaponFrame) + assertEquals(PS_WEAPONINDEX, offsetsOnly.deltaFlags) + } + + @Test + fun `blend retains every byte and stats retain every slot including explicit zeroes`() { + for (value in 0..255) { + val message = PlayerInfoMessage(blend = PlayerBlend(value, value, value, value)) + assertEquals(message, read(write(message))) + val engine = player_state_t().also(message::applyTo) + assertArrayEquals(FloatArray(4) { value / 255f }, engine.blend) + } + for (slot in 0 until MAX_STATS) { + val message = PlayerInfoMessage(stats = PlayerStatDelta(mapOf(slot to 0))) + assertEquals(1 shl slot, message.stats.mask) + assertEquals(message, read(write(message))) + val engine = player_state_t().apply { stats.fill(7) }.also(message::applyTo) + assertArrayEquals(ShortArray(MAX_STATS) { if (it == slot) 0 else 7 }, engine.stats) + } + } + + @Test + fun `queued messages own engine arrays and selected stats and never share reconstructed storage`() { + val previous = player_state_t() + val current = player_state_t().apply { + pmove.origin[0] = 12; viewoffset[1] = -2.25f; viewangles[2] = -90f + gunindex = 4; gunframe = 5; gunangles[1] = 1.25f + blend[2] = 0.5f; stats[31] = -1 + } + val message = PlayerInfoMessage.fromGame(previous, current) + val bytes = write(message) + val hash = message.hashCode() + previous.set(current); current.clear() + assertArrayEquals(bytes, write(message)) + assertEquals(hash, message.hashCode()) + val first = player_state_t().also(message::applyTo) + val second = player_state_t().also(message::applyTo) + first.clear() + assertEquals(read(bytes), message) + assertEquals(12, second.pmove.origin[0].toInt()) + assertEquals(-1, second.stats[31].toInt()) + assertArrayEquals(bytes, write(message)) + val selected = mutableMapOf(31 to -1) + val stats = PlayerStatDelta(selected) + selected.clear() + assertEquals(-1, stats[31]) + assertNotEquals(PlayerInfoMessage(), PlayerInfoMessage(weaponIndex = 0)) + } + + @Test + fun `every truncated full player update rejects the whole body`() { + val raw = sizebuf_t(256).apply { + writeByte(17); writeShort(65535) + repeat(253) { writeByte(255.toByte()) } + readcount = 1 + } + LegacyPlayerInfoReference().parse(raw) + val bytes = raw.data.copyOf(raw.readcount) + for (length in 1 until bytes.size) { + val wire = PacketCodec.encodeSequenced(1, 0, null, PacketBytes.copyOf(byteArrayOf(7) + bytes.copyOf(length))) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + assertEquals(MessageRejection.TRUNCATED, (MessageBodyCodec.decodeServer(envelope) as MessageDecodeResult.Rejected).reason) + } + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 647f8b61..9f470804 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -1390,7 +1390,7 @@ Collection buildClientFrameMessages(ClientNetworkInfo client) { client.surpressCount = 0; // delta encode the playerstate - PlayerInfoMessage playerInfoMsg = new PlayerInfoMessage(lastReceivedFrame != null ? lastReceivedFrame.ps : new player_state_t(), currentFrame.ps); + PlayerInfoMessage playerInfoMsg = PlayerInfoMessage.fromGame(lastReceivedFrame != null ? lastReceivedFrame.ps : new player_state_t(), currentFrame.ps); result.add(playerInfoMsg); // delta encode the entities From 48779619d5440e2f17278658c8c161e647134545 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 00:02:50 +0100 Subject: [PATCH 48/57] Replace legacy netchan adapter with Kotlin ConnectionChannel --- .../java/jake2/server/GameImportsTest.java | 26 +-- .../jake2/server/EmbeddedCakeHostTest.kt | 4 +- .../jake2/server/GameContinuationTest.kt | 18 +- .../jake2/server/HostClientAccountingTest.kt | 16 +- .../kotlin/jake2/server/HostNetworkFixture.kt | 13 ++ .../server/HostSessionPreparationTest.kt | 6 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 42 ++-- .../org/demoth/cake/network/ClientNetwork.kt | 2 +- .../cake/network/ClientHandshakeTest.kt | 4 +- .../demoth/cake/network/ClientNetworkTest.kt | 2 +- .../java/jake2/qcommon/network/netchan_t.java | 199 ------------------ .../qcommon/network/ConnectionChannel.kt | 179 ++++++++++++++++ .../qcommon/network/runtime/PacketNetwork.kt | 12 +- .../jake2/qcommon/network/NetchanTest.java | 71 ++++--- .../network/runtime/ConnectionChannelTest.kt | 135 ++++++++++++ .../network/runtime/OwnedChannelTest.kt | 34 +-- .../network/runtime/PacketNetworkTest.kt | 4 +- .../java/jake2/server/ClientNetworkInfo.java | 8 +- .../java/jake2/server/GameImportsImpl.java | 6 +- .../kotlin/jake2/server/ServerConnections.kt | 10 +- .../main/kotlin/jake2/server/ServerHost.kt | 48 ++--- .../main/kotlin/jake2/server/ServerNetwork.kt | 2 +- .../jake2/server/ServerConnectionsTest.kt | 22 +- .../kotlin/jake2/server/ServerNetworkTest.kt | 6 +- 24 files changed, 504 insertions(+), 365 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/netchan_t.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 333b32c4..5e31d1ab 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -111,17 +111,17 @@ public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { Assertions.assertEquals(firstIndex, first.modelindex("models/first.md2")); Assertions.assertEquals("models/first.md2", first.configstrings[Defines.CS_MODELS + firstIndex]); Assertions.assertEquals("models/second.md2", second.configstrings[Defines.CS_MODELS + secondIndex]); - Assertions.assertTrue(firstClient.netchan.reliablePending.isEmpty()); - Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + Assertions.assertTrue(firstClient.netchan.getQueuedReliable().isEmpty()); + Assertions.assertTrue(secondClient.netchan.getQueuedReliable().isEmpty()); first.state = ServerStates.SS_GAME; int soundIndex = first.soundindex("sound/first.wav"); // Reusing an index should not publish another resource update. Assertions.assertEquals(soundIndex, first.soundindex("sound/first.wav")); - Assertions.assertEquals(1, firstClient.netchan.reliablePending.size()); + Assertions.assertEquals(1, firstClient.netchan.getQueuedReliable().size()); Assertions.assertEquals(new ConfigStringMessage(Defines.CS_SOUNDS + soundIndex, "sound/first.wav"), - firstClient.netchan.reliablePending.iterator().next()); - Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + firstClient.netchan.getQueuedReliable().iterator().next()); + Assertions.assertTrue(secondClient.netchan.getQueuedReliable().isEmpty()); } finally { // These clients exercise message queues without establishing network channels. firstClient.state = secondClient.state = ClientStates.CS_FREE; @@ -261,11 +261,11 @@ public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { first.positioned_sound(explicitOrigin, entity, Defines.CHAN_RELIABLE | Defines.CHAN_VOICE, 5, 1, Defines.ATTN_NONE, 0); explicitOrigin[0] = 99; - Assertions.assertEquals(1, firstClient.netchan.reliablePending.size()); - SoundMessage reliable = (SoundMessage) firstClient.netchan.reliablePending.iterator().next(); + Assertions.assertEquals(1, firstClient.netchan.getQueuedReliable().size()); + SoundMessage reliable = (SoundMessage) firstClient.netchan.getQueuedReliable().iterator().next(); Assertions.assertArrayEquals(new float[]{1, 2, 3}, reliable.getOrigin().toArray()); Assertions.assertTrue(secondClient.unreliable.isEmpty()); - Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + Assertions.assertTrue(secondClient.netchan.getQueuedReliable().isEmpty()); } finally { firstClient.state = secondClient.state = ClientStates.CS_FREE; } @@ -278,7 +278,7 @@ public void clientPrintQueueRespectsItsMessageLevel() { client.queuePrint(Defines.PRINT_LOW, "hidden"); client.queuePrint(Defines.PRINT_HIGH, "visible"); Assertions.assertEquals(List.of(new PrintMessage(Defines.PRINT_HIGH, "visible")), - List.copyOf(client.netchan.reliablePending)); + List.copyOf(client.netchan.getQueuedReliable())); Assertions.assertTrue(client.unreliable.isEmpty()); } @@ -368,12 +368,12 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { manager.executeClientCommand(firstClient, "new"); manager.executeClientCommand(secondClient, "new"); Assertions.assertEquals("first game", - ((ServerDataMessage) firstClient.netchan.reliablePending.iterator().next()).getLevelString()); + ((ServerDataMessage) firstClient.netchan.getQueuedReliable().iterator().next()).getLevelString()); Assertions.assertEquals("second game", - ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).getLevelString()); - firstClient.netchan.reliablePending.clear(); + ((ServerDataMessage) secondClient.netchan.getQueuedReliable().iterator().next()).getLevelString()); + firstClient.netchan.clearQueuedReliable(); manager.executeClientCommand(firstClient, "configstrings " + first.spawncount + " 0"); - Assertions.assertTrue(firstClient.netchan.reliablePending.contains( + Assertions.assertTrue(firstClient.netchan.getQueuedReliable().contains( new ConfigStringMessage(Defines.CS_NAME, "first game"))); } finally { firstClient.state = secondClient.state = ClientStates.CS_FREE; diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 6f30bccc..9faa5175 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -165,10 +165,10 @@ class EmbeddedCakeHostTest { downloadManager = CakeDownloadManager(CakeDownloadCache(cache), cvars), ) assertTrue(downloads.requestMissingResource(path, null, 1)) - assertEquals(1, client.netchan.reliablePending.size, + assertEquals(1, client.netchan.queuedReliable.size, "Missing files must receive a reply instead of leaving the client waiting") val buffer = sizebuf_t(64) - client.netchan.reliablePending.single().writeTo(buffer) + client.netchan.queuedReliable.single().writeTo(buffer) val reply = ServerMessage.parseFromBuffer(buffer) as DownloadMessage assertTrue(reply is DownloadMessage.Failure) assertEquals(CakeDownloadSession.Result.MissingOnServer, downloads.handle(reply)) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 5e27aee3..a41f522d 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -64,7 +64,7 @@ class GameContinuationTest { gameName = game.name edict = game.gameExports.getEdict(index + 1) state = ClientStates.CS_CONNECTED - netchan.setup(Defines.NS_SERVER, netadr_t(), index) + netchan.setup(netadr_t(), index) } private fun finish(client: ClientNetworkInfo, game: GameImportsImpl) = @@ -137,9 +137,9 @@ class GameContinuationTest { assertSame(second, host.games["other"]) assertEquals("default", a.gameName) assertEquals("default", c.gameName) - assertTrue(a.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) - assertTrue(c.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) - assertFalse(b.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) + assertTrue(a.netchan.queuedReliable.contains(StuffTextMessage.command("reconnect"))) + assertTrue(c.netchan.queuedReliable.contains(StuffTextMessage.command("reconnect"))) + assertFalse(b.netchan.queuedReliable.contains(StuffTextMessage.command("reconnect"))) finish(b, second) host.update(100) @@ -189,9 +189,9 @@ class GameContinuationTest { finish(b, second) a.state = ClientStates.CS_SPAWNED a.download = byteArrayOf(1) - a.netchan.reliablePending.add(StuffTextMessage.command("stale")) + a.netchan.queueReliable(StuffTextMessage.command("stale")) a.unreliable.add(StuffTextMessage.command("stale")) - a.netchan.remote_address = null // Fail the first final notification. + network.failTransmissions(a) // Fail the first final notification. first.gameExports = null // Discarding a Game must not call back into its gameplay. Cvar.getInstance().find("port").forceSet(0) assertTrue(network.server.configure(true)) @@ -205,7 +205,7 @@ class GameContinuationTest { assertNull(client.gameName) assertNull(client.edict) assertNull(client.download) - assertTrue(client.netchan.reliablePending.isEmpty()) + assertTrue(client.netchan.queuedReliable.isEmpty()) assertTrue(client.unreliable.isEmpty()) } assertSame(second, host.games["other"]) @@ -298,7 +298,7 @@ class GameContinuationTest { @Test fun `failed cleanup escapes nested Game boundaries with original failure`() { val first = game("default", "testbox") - bind(0, first).netchan.remote_address = null + network.failTransmissions(bind(0, first)) val failure = GameFailure(first, "failed frame") first.gameExports = object : GameExports by first.gameExports { override fun G_RunFrame() { throw failure } @@ -368,7 +368,7 @@ class GameContinuationTest { assertEquals("default", client.gameName) assertEquals("testbox2", host.games.getValue("other").mapName) assertNull(first.takeNextMap(), "Rejected continuation remains consumed") - assertFalse(client.netchan.reliablePending.contains(StuffTextMessage.command("reconnect"))) + assertFalse(client.netchan.queuedReliable.contains(StuffTextMessage.command("reconnect"))) } @ParameterizedTest diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 35ced0c1..02d3ee2f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -88,7 +88,7 @@ class HostClientAccountingTest { lastconnect = 100 rate = 100000 state = ClientStates.CS_CONNECTED - netchan.setup(Defines.NS_SERVER, netadr_t(), index) + netchan.setup(netadr_t(), index) } @AfterEach @@ -190,14 +190,14 @@ class HostClientAccountingTest { fun `reliable overflow disconnects only offending connection and peers still receive messages`() { val client = bind(0, first) val peer = bind(1, first) - client.netchan.reliablePending.add( + client.netchan.queueReliable( jake2.qcommon.network.messages.server.StuffTextMessage.command("x".repeat(Defines.MAX_MSGLEN))) - peer.netchan.reliablePending.add(jake2.qcommon.network.messages.server.StuffTextMessage.command("peer")) - val previousSequence = peer.netchan.outgoing_sequence + peer.netchan.queueReliable(jake2.qcommon.network.messages.server.StuffTextMessage.command("peer")) + val previousSequence = peer.netchan.outgoingSequence host.sendClientMessages() assertEquals(ClientStates.CS_ZOMBIE, client.state) assertEquals(ClientStates.CS_CONNECTED, peer.state) - assertTrue(peer.netchan.outgoing_sequence > previousSequence) + assertTrue(peer.netchan.outgoingSequence > previousSequence) assertSame(first, host.games[first.name]) assertSame(second, host.games[second.name]) } @@ -210,13 +210,13 @@ class HostClientAccountingTest { client.downloadsize = source.size client.downloadcount = source.size - 2048 host.executeClientCommand(client, "nextdl") - val firstChunk = client.netchan.reliablePending.single() as DownloadMessage.Chunk + val firstChunk = client.netchan.queuedReliable.single() as DownloadMessage.Chunk assertEquals(99, firstChunk.percentage) assertEquals(1024, firstChunk.data.size) source[source.size - 2048] = 77 assertEquals(0.toByte(), firstChunk.data.toByteArray()[0]) host.executeClientCommand(client, "nextdl") - val lastChunk = client.netchan.reliablePending.last() as DownloadMessage.Chunk + val lastChunk = client.netchan.queuedReliable.last() as DownloadMessage.Chunk assertEquals(100, lastChunk.percentage) assertEquals(1024, lastChunk.data.size) assertNull(client.download) @@ -315,7 +315,7 @@ class HostClientAccountingTest { fun `rate history expires by Host frames and movement budget ignores Game age`() { val a = bind(0, first) val b = bind(1, second) - a.netchan.remote_address.type = NetAddrType.NA_IP + a.netchan.setup(netadr_t().apply { type = NetAddrType.NA_IP }, a.netchan.qport) a.rate = 1 a.message_size[1] = 100 repeat(10) { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt index 5c1eb7c2..bb8d9a91 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt @@ -14,6 +14,19 @@ internal class HostNetworkFixture : AutoCloseable { val server = TransportServerNetwork(serverTransport) val client = PacketNetwork(clientTransport, NetworkRole.CLIENT) + /** Inject a programming failure at send time without corrupting connection address state. */ + fun failTransmissions(client: ClientNetworkInfo) { + val previous = client.netchan + client.netchan = jake2.qcommon.network.ConnectionChannel( + ChannelRole.SERVER, serverTransport.localPeer, + { _, _ -> throw IllegalStateException("injected send failure") }, + clockMillis = { 0L }, + ).also { replacement -> + replacement.setup(previous.remoteAddress, previous.qport) + previous.queuedReliable.forEach(replacement::queueReliable) + } + } + fun clearLocalTraffic() { link.first.clearIncoming() link.second.clearIncoming() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 69512c04..a6ab050f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -501,7 +501,7 @@ class HostSessionPreparationTest { val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") val client = host.clients[0] client.state = ClientStates.CS_CONNECTED - client.netchan.setup(Defines.NS_SERVER, jake2.qcommon.network.netadr_t(), 0) + client.netchan.setup(jake2.qcommon.network.netadr_t(), 0) network.sendLocal(Defines.NS_CLIENT, 8, ByteArray(8)) cvars.find("paused").forceSet(true) var deferredRan = false @@ -553,8 +553,8 @@ class HostSessionPreparationTest { save(true) val client = host.clients[0] client.state = ClientStates.CS_CONNECTED - client.netchan.remote_address = null // Fault injection at final packet transmission. - assertThrows(NullPointerException::class.java) { host.close() } + network.failTransmissions(client) // Fault injection at final packet transmission. + assertThrows(IllegalStateException::class.java) { host.close() } assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertNull(network.serverTransport.udpEndpoint) 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 3ffcc1c7..9cd1513b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -136,7 +136,7 @@ class Cake( private val netchan = network.newChannel() private val downloads = CakeDownloadSession(sendReliable = { command -> - netchan.reliablePending.add(StringCmdMessage(command)) + netchan.queueReliable(StringCmdMessage(command)) }) private val networkDebugSampler = NetworkDebugSampler() private var clientCommandExtraMs = 0f @@ -302,7 +302,7 @@ class Cake( if (networkState == CONNECTED) { // set on "changing" command Com.Printf("Sending 'new' to server\n") - netchan.reliablePending.add(StringCmdMessage(StringCmdMessage.NEW)) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.NEW)) } else { val target = handshake.lastTarget ?: return@AddCommand @@ -364,7 +364,7 @@ class Cake( } val to = if (networkState == CONNECTED || networkState == ACTIVE) { - netchan.remote_address + netchan.remoteAddress } else { val rconAddress = Cvar.getInstance().find("rcon_address").typedValue if (rconAddress.isEmpty()) { @@ -440,7 +440,7 @@ class Cake( Com.Warn("Cannot cmd '${it}', not connected") } else { if (it.size > 1) - netchan.reliablePending.add(StringCmdMessage(getArguments(it))) + netchan.queueReliable(StringCmdMessage(getArguments(it))) else Com.Warn("Empty cmd") // todo: warning } @@ -474,7 +474,7 @@ class Cake( downloads.precacheCompleted() // we are ready to start the game! - netchan.reliablePending.add(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) } // endregion @@ -532,7 +532,7 @@ class Cake( private fun transmitAndRecord(unreliable: Collection?) { if (netchan.transmit(unreliable)) { - networkDebugSampler.recordOutbound(netchan.last_sent_size, Globals.curtime) + networkDebugSampler.recordOutbound(netchan.lastSentSize, Globals.curtime) } } @@ -701,8 +701,8 @@ class Cake( // Cross-reference: old client reads `cls.netchan.incoming_acknowledged/outgoing_sequence` // in `CL_pred.PredictMovement`. game3dScreen?.updatePredictionNetworkState( - netchan.incoming_acknowledged, - netchan.outgoing_sequence, + netchan.incomingAcknowledged, + netchan.outgoingSequence, Globals.curtime ) game3dScreen?.render(deltaSeconds) @@ -735,7 +735,7 @@ class Cake( if (debugGraphStage.hasEnabledMetrics()) { val profiler = if (glProfilerActive) glProfiler else null - val networkSnapshot = networkDebugSampler.snapshot(Globals.curtime, netchan.smoothed_ping_ms) + val networkSnapshot = networkDebugSampler.snapshot(Globals.curtime, netchan.smoothedPingMillis) debugGraphStage.collectMetrics(profiler, networkSnapshot) debugGraphStage.act(deltaSeconds) debugGraphStage.draw() @@ -785,13 +785,13 @@ class Cake( */ private fun sendUpdates() { game3dScreen?.pollCinematicSkipCommand(Globals.curtime)?.let { skipCommand -> - netchan.reliablePending.add(skipCommand) + netchan.queueReliable(skipCommand) } when (networkState) { CONNECTING, DISCONNECTED -> return CONNECTED -> { - if (netchan.reliablePending.isNotEmpty() || Globals.curtime - netchan.last_sent > CONNECTED_KEEPALIVE_TIMEOUT_MS) { + if (netchan.hasQueuedReliable || netchan.hasBeenIdleFor(CONNECTED_KEEPALIVE_TIMEOUT_MS.toLong())) { // send pending reliable messages (e.g. "new") immediately transmitAndRecord(null) } @@ -799,7 +799,7 @@ class Cake( ACTIVE -> { queueUserInfoUpdateIfNeeded() - game3dScreen?.buildMoveMessage(netchan.outgoing_sequence, currentClientCommandMsec())?.let { + game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec())?.let { transmitAndRecord(listOf(it)) } } @@ -840,7 +840,7 @@ class Cake( @Suppress("DEPRECATION") private fun queueUserInfoUpdateIfNeeded() { if (Globals.userinfo_modified) { - netchan.reliablePending.add(UserInfoMessage(Cvar.getInstance().Userinfo())) + netchan.queueReliable(UserInfoMessage(Cvar.getInstance().Userinfo())) Globals.userinfo_modified = false } } @@ -1073,14 +1073,14 @@ class Cake( // // packet from server // - if (networkPacket.from.type != netchan.remote_address.type || - !networkPacket.from.compareIp(netchan.remote_address)) { + if (networkPacket.from.type != netchan.remoteAddress.type || + !networkPacket.from.compareIp(netchan.remoteAddress)) { Com.Warn(networkPacket.from.toString() + ": sequenced packet without connection\n") return@poll } if (netchan.accept(networkPacket)) { - networkDebugSampler.recordInbound(netchan.last_received_size, Globals.curtime) + networkDebugSampler.recordInbound(netchan.lastReceivedSize, Globals.curtime) when (val body = networkPacket.parseBodyFromServer()) { is MessageDecodeResult.Decoded -> { if (Netchan.printNetwork?.typedValue == true) body.messages.forEach { message -> @@ -1108,8 +1108,8 @@ class Cake( // payload messages. We push those values into prediction first, mirroring old `CL_ReadPackets` // + `CL_pred.CheckPredictionError/PredictMovement` data flow. game3dScreen?.updatePredictionNetworkState( - netchan.incoming_acknowledged, - netchan.outgoing_sequence, + netchan.incomingAcknowledged, + netchan.outgoingSequence, Globals.curtime ) messages.forEach { msg -> @@ -1148,7 +1148,7 @@ class Cake( // new game is starting Cbuf.Execute() resetClientStateForServerData() - netchan.reliablePending.clear() + netchan.clearQueuedReliable() menuStages.hideContentPreviews() fileResolver.gamemod = msg.gameName.takeIf { !it.isNullOrBlank() } @@ -1327,8 +1327,8 @@ class Cake( ConnectionlessCommand.client_connect -> { if (!handshake.acceptConnection(packet.from)) return networkState = CONNECTED - netchan.setup(NS_CLIENT, packet.from, Cvar.getInstance().find("qport").typedValue) - netchan.reliablePending.add(StringCmdMessage(StringCmdMessage.NEW)) + netchan.setup(packet.from, Cvar.getInstance().find("qport").typedValue) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.NEW)) Com.Println("Connected!") } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index 52142c26..ef2dc369 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -10,7 +10,7 @@ import jake2.qcommon.network.runtime.NetworkRole /** Client transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ClientNetwork : AutoCloseable { fun configure(multiplayer: Boolean): Boolean - fun newChannel(): netchan_t + fun newChannel(): ConnectionChannel fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) fun stop() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt index 15d7fac7..50dda442 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt @@ -2,7 +2,7 @@ package org.demoth.cake.network import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket import org.junit.jupiter.api.Assertions.* @@ -18,7 +18,7 @@ class ClientHandshakeTest { binds += multiplayer return bindSucceeds } - override fun newChannel(): netchan_t = error("Handshake must not create protocol channels") + override fun newChannel(): ConnectionChannel = error("Handshake must not create protocol channels") override fun poll(budget: Int, consume: (NetworkPacket) -> Unit): Int = error("Caller owns polling") override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { sent += Sent(to.ip.toList(), to.port, command, payload) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt index 006ee3d9..5363fcc8 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt @@ -34,7 +34,7 @@ class ClientNetworkTest { assertEquals("getchallenge\n", received.datagram.payload.toByteArray() .drop(4).toByteArray().toString(Charsets.ISO_8859_1)) val channel = client.newChannel() - channel.setup(jake2.qcommon.Defines.NS_CLIENT, netadr_t(), 42) + channel.setup(netadr_t(), 42) assertTrue(channel.transmit(null)) assertTrue(link.second.receive() is ReceiveResult.Received) } diff --git a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java deleted file mode 100644 index 0bea187c..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 27.11.2003 by RST. -// $Id: netchan_t.java,v 1.2 2004-09-22 19:22:09 salomo Exp $ -package jake2.qcommon.network; - -import jake2.qcommon.Com; -import jake2.qcommon.Defines; -import jake2.qcommon.Globals; -import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.messages.NetworkPacket; -import jake2.qcommon.network.protocol.*; -import jake2.qcommon.network.transport.PacketBytes; -import jake2.qcommon.network.transport.SendResult; -import jake2.qcommon.network.runtime.NetworkTransport; -import jake2.qcommon.sizebuf_t; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.function.LongSupplier; - -/** Legacy engine adapter. Protocol state and owned reliable bytes live in ReliableChannel. */ -public class netchan_t { - public boolean fatal_error; - public int sock; - public netadr_t remote_address = new netadr_t(); - public int qport; - - // Compatibility snapshots for existing engine consumers; never used as protocol storage. - public int dropped; - public int last_received; - public int last_sent; - public int incoming_sequence; - public int incoming_acknowledged; - public int incoming_reliable_acknowledged; - public int reliable_received_flag; - public int reliable_sent_flag; - public int outgoing_sequence = 1; - public int last_reliable_sequence; - public int last_received_size; - public int last_sent_size; - public int smoothed_ping_ms; - - /** Engine message staging remains here until session controllers own message encoding. */ - public final Collection reliablePending = new ArrayList<>(); - - private final LongSupplier clockMillis; - private final NetworkTransport transport; - private ReliableChannel channel = new ReliableChannel(ChannelRole.CLIENT, 0); - - /** - * The channel borrows its owner's transport; clearing a connection never closes it. - * Supply both NetworkTransport and a clock explicitly instead of selecting a global transport. - */ - public netchan_t(NetworkTransport transport, LongSupplier clockMillis) { - this.transport = java.util.Objects.requireNonNull(transport); - this.clockMillis = java.util.Objects.requireNonNull(clockMillis); - } - - public void setup(int sock, netadr_t adr, int qport) { - clear(); - this.sock = sock; - remote_address.set(adr); - this.qport = qport; - channel = new ReliableChannel(sock == Defines.NS_CLIENT ? ChannelRole.CLIENT : ChannelRole.SERVER, - qport & 0xffff); - last_received = Globals.curtime; - syncState(); - } - - public boolean accept(NetworkPacket packet) { - if (packet.isConnectionless()) return false; - ChannelAcceptResult result = channel.accept(packet.sequencedEnvelope(), clockMillis.getAsLong()); - // Keep the legacy message-processing view free of the reliable flag bits. - packet.sequence &= Integer.MAX_VALUE; - packet.sequenceAck &= Integer.MAX_VALUE; - if (!(result instanceof ChannelAcceptResult.Accepted)) return false; - last_received = Globals.curtime; - syncState(); - return true; - } - - /** Returns true only after the transport accepted the datagram. */ - public boolean transmit(Collection unreliable) { - if (reliablePending.stream().mapToLong(NetworkMessage::getSize).sum() > Defines.MAX_MSGLEN - 16) { - fatal_error = true; - Com.Printf(remote_address + ":Outgoing message overflow\n"); - return false; - } - boolean staged = false; - boolean sent = false; - try { - if (channel.getCanSendReliable() && !reliablePending.isEmpty()) { - PacketBytes reliable = encodeBatch(reliablePending, Defines.MAX_MSGLEN - 16, null); - if (channel.enqueueReliable(reliable) != ReliableQueueResult.QUEUED) { - fatal_error = true; - return false; - } - staged = true; - } - PrepareSendResult result = channel.prepareSend((sequence, capacity) -> encodeBatch(unreliable, capacity, sequence)); - if (result instanceof PrepareSendResult.SequenceExhausted) { - fatal_error = true; - return false; - } - PrepareSendResult.Prepared transmission = (PrepareSendResult.Prepared) result; - try { - sent = send(transmission.getBytes()) instanceof SendResult.Sent; - } finally { - channel.completeSend(transmission, sent, clockMillis.getAsLong()); - } - if (sent) { - last_sent = Globals.curtime; - if (transmission.getUnreliableDropped()) Com.Printf("Netchan_Transmit: dumped unreliable\n"); - if (Netchan.showpackets != null && Netchan.showpackets.getTypedValue()) { - Com.Printf(String.format("Netchan: %s | out: %6d | in: %6d | outr: %1d | inr: %1d \n", - sock == Defines.NS_CLIENT ? "client" : "server", transmission.getSequence(), - channel.getIncomingSequence(), transmission.getContainsReliable() ? 1 : 0, - channel.getReliableReceivedFlag())); - } - } - return sent; - } finally { - if (staged) { - if (sent) reliablePending.clear(); - else channel.discardPending(); // Leave unsent message staging available to existing cancellation paths. - } - syncState(); - } - } - - private SendResult send(PacketBytes bytes) { - try { - return transport.send(remote_address.toNetworkAddress(transport.getLocalPeer()), bytes); - } catch (java.io.IOException error) { - return new SendResult.Failed(error); - } - } - - private static PacketBytes encodeBatch(Collection messages, int capacity, Integer sequence) { - if (messages == null || messages.isEmpty()) return PacketBytes.copyOf(new byte[0]); - if (messages.stream().mapToLong(NetworkMessage::getSize).sum() > capacity) return null; - sizebuf_t buffer = new sizebuf_t(capacity); - messages.forEach(message -> { - if (sequence == null) message.writeTo(buffer); - else message.writeTo(buffer, sequence); - }); - return PacketBytes.copyOf(buffer.data, buffer.cursize); - } - - public boolean canReliable() { - return channel.getCanSendReliable(); - } - - public boolean needReliable() { - return channel.getNeedsReliable() || (channel.getCanSendReliable() && !reliablePending.isEmpty()); - } - - public void clear() { - fatal_error = false; - sock = qport = last_received = last_sent = 0; - remote_address = new netadr_t(); - reliablePending.clear(); - channel = new ReliableChannel(ChannelRole.CLIENT, 0); - syncState(); - } - - private void syncState() { - dropped = channel.getDropped(); - incoming_sequence = channel.getIncomingSequence(); - incoming_acknowledged = channel.getIncomingAcknowledged(); - incoming_reliable_acknowledged = channel.getIncomingReliableAcknowledged(); - reliable_received_flag = channel.getReliableReceivedFlag(); - reliable_sent_flag = channel.getReliableSentFlag(); - outgoing_sequence = channel.getOutgoingSequence(); - last_reliable_sequence = channel.getLastReliableSequence(); - last_received_size = channel.getLastReceivedSize(); - last_sent_size = channel.getLastSentSize(); - smoothed_ping_ms = channel.getSmoothedPingMillis(); - } -} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt new file mode 100644 index 00000000..9eb9729a --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 1997-2001 Id Software, Inc. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 59 Temple + * Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ + +// Created on 27.11.2003 by RST. +// $Id: netchan_t.java,v 1.2 2004-09-22 19:22:09 salomo Exp $ +package jake2.qcommon.network + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.* +import jake2.qcommon.sizebuf_t +import java.io.IOException +import java.util.Collections +import java.util.function.LongSupplier + +/** + * Engine connection adapter, confined to its owner's game-loop thread. Protocol state and reliable + * bytes belong to [ReliableChannel]. The sender borrows its owner's transport; clearing a connection + * never closes it. Transport, monotonic clock, and diagnostics are supplied explicitly. + */ +class ConnectionChannel @JvmOverloads constructor( + private val role: ChannelRole, + private val localPeer: NetworkAddress.Local, + private val sendPacket: (NetworkAddress, PacketBytes) -> SendResult, + private val logPackets: () -> Boolean = { false }, + private val log: (String) -> Unit = {}, + private val clockMillis: LongSupplier, +) { + private var channel = ReliableChannel(role, 0) + private var destination = netadr_t() + private val pending = ArrayList() + private var idleSinceMillis = clockMillis.asLong + + var fatalError = false + private set + var qport = 0 + private set + + // Read protocol state directly; these values are never mutable compatibility snapshots. + val incomingSequence: Int get() = channel.incomingSequence + val incomingAcknowledged: Int get() = channel.incomingAcknowledged + val outgoingSequence: Int get() = channel.outgoingSequence + val reliableSentFlag: Int get() = channel.reliableSentFlag + val dropped: Int get() = channel.dropped + val lastReceivedSize: Int get() = channel.lastReceivedSize + val lastSentSize: Int get() = channel.lastSentSize + val lastSentMillis: Long get() = channel.lastSentMillis + val smoothedPingMillis: Int get() = channel.smoothedPingMillis + val canSendReliable: Boolean get() = channel.canSendReliable + val needsReliable: Boolean get() = channel.needsReliable || (channel.canSendReliable && pending.isNotEmpty()) + + /** Detached legacy address view until active address boundaries migrate to immutable values. */ + val remoteAddress: netadr_t get() = netadr_t().also { it.set(destination) } + + /** Engine staging remains here until session controllers own message encoding. No mutable list escapes. */ + fun queueReliable(message: NetworkMessage) { pending.add(message) } + fun clearQueuedReliable() { pending.clear() } + val hasQueuedReliable: Boolean get() = pending.isNotEmpty() + val queuedReliableBytes: Long get() = pending.sumOf { it.size.toLong() } + val queuedReliable: List get() = Collections.unmodifiableList(ArrayList(pending)) + + fun setup(address: netadr_t, qport: Int) { + clear() + destination.set(address) + this.qport = qport + channel = ReliableChannel(role, qport and 0xffff) + } + + /** Commit NAT source-port changes only after the router accepts the sequenced packet. */ + fun updateRemotePort(port: Int) { + require(port in 0..65535) + destination.port = port + } + + /** Keepalive timing shares the injected monotonic clock with acknowledgements and RTT tracking. */ + fun hasBeenIdleFor(millis: Long): Boolean { + require(millis >= 0) + return clockMillis.asLong - idleSinceMillis > millis + } + + fun accept(packet: NetworkPacket): Boolean { + if (packet.isConnectionless) return false + val result = channel.accept(packet.sequencedEnvelope(), clockMillis.asLong) + // Keep the engine message-processing view free of reliable flag bits. + packet.sequence = packet.sequence and Int.MAX_VALUE + packet.sequenceAck = packet.sequenceAck and Int.MAX_VALUE + return result is ChannelAcceptResult.Accepted + } + + /** Returns true only after the transport accepted the datagram. */ + fun transmit(unreliable: Collection?): Boolean { + if (queuedReliableBytes > Defines.MAX_MSGLEN - 16) { + fatalError = true + log("$destination:Outgoing message overflow\n") + return false + } + var staged = false + var sent = false + try { + if (channel.canSendReliable && pending.isNotEmpty()) { + val reliable = requireNotNull(encodeBatch(pending, Defines.MAX_MSGLEN - 16, null)) + if (channel.enqueueReliable(reliable) != ReliableQueueResult.QUEUED) { + fatalError = true + return false + } + staged = true + } + val result = channel.prepareSend { sequence, capacity -> encodeBatch(unreliable, capacity, sequence) } + if (result is PrepareSendResult.SequenceExhausted) { + fatalError = true + return false + } + val transmission = result as PrepareSendResult.Prepared + try { + sent = send(transmission.bytes) is SendResult.Sent + } finally { + channel.completeSend(transmission, sent, clockMillis.asLong) + } + if (sent) { + idleSinceMillis = channel.lastSentMillis + if (transmission.unreliableDropped) log("Netchan_Transmit: dumped unreliable\n") + if (logPackets()) log(String.format( + "Netchan: %s | out: %6d | in: %6d | outr: %1d | inr: %1d \n", + if (role == ChannelRole.CLIENT) "client" else "server", transmission.sequence, + channel.incomingSequence, if (transmission.containsReliable) 1 else 0, channel.reliableReceivedFlag)) + } + return sent + } finally { + if (staged) { + if (sent) pending.clear() + else channel.discardPending() // Leave unsent staging available to cancellation paths. + } + } + } + + private fun send(bytes: PacketBytes): SendResult = try { + sendPacket(destination.toNetworkAddress(localPeer), bytes) + } catch (error: IOException) { + SendResult.Failed(error) + } + + private fun encodeBatch(messages: Collection?, capacity: Int, sequence: Int?): PacketBytes? { + if (messages.isNullOrEmpty()) return PacketBytes.copyOf(byteArrayOf()) + if (messages.sumOf { it.size.toLong() } > capacity) return null + val buffer = sizebuf_t(capacity) + for (message in messages) { + if (sequence == null) message.writeTo(buffer) else message.writeTo(buffer, sequence) + } + return PacketBytes.copyOf(buffer.data, buffer.cursize) + } + + fun clear() { + fatalError = false + qport = 0 + destination = netadr_t() + pending.clear() + channel = ReliableChannel(role, 0) + idleSinceMillis = clockMillis.asLong + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index dc6a5861..c71ca941 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -5,7 +5,7 @@ import jake2.qcommon.Defines import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.protocol.* @@ -32,9 +32,15 @@ class PacketNetwork( private val label = if (role == NetworkRole.CLIENT) "Client" else "Server" private val direction = if (role == NetworkRole.CLIENT) PacketDirection.FROM_SERVER else PacketDirection.FROM_CLIENT - fun newChannel(): netchan_t { + fun newChannel(): ConnectionChannel { check(!closed) { "$label network is closed" } - return netchan_t(transport) { System.nanoTime() / 1_000_000L } + return ConnectionChannel( + if (role == NetworkRole.CLIENT) ChannelRole.CLIENT else ChannelRole.SERVER, + transport.localPeer, transport::send, + logPackets = { Netchan.showpackets?.typedValue == true }, + log = { Com.Printf(it) }, + clockMillis = { System.nanoTime() / 1_000_000L }, + ) } /** diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index 3ceb14ba..4298494f 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -1,6 +1,8 @@ package jake2.qcommon.network; import jake2.qcommon.Globals; +import jake2.qcommon.network.protocol.ChannelRole; +import jake2.qcommon.network.transport.SendResult; import jake2.qcommon.exec.Cvar; import jake2.qcommon.network.messages.client.StringCmdMessage; import jake2.qcommon.network.messages.NetworkPacket; @@ -47,75 +49,78 @@ void cleanup() throws Exception { @Test void outgoingClientPacketUsesItsSetupQport() { Netchan.qport.forceSet(77); - var channel = new netchan_t(client, () -> Globals.curtime); - channel.setup(NS_CLIENT, new netadr_t(), 1234); + var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), client::send, () -> (long) Globals.curtime); + channel.setup(new netadr_t(), 1234); assertTrue(channel.transmit(null)); assertEquals(1234, serverPackets.receive().qport); } @Test - void unavailableTransportDoesNotAdvanceOrLoseStagedMessages() { - var channel = new netchan_t(client, () -> Globals.curtime); - var remote = new netadr_t(); - remote.type = NetAddrType.NA_IP; - remote.port = 27910; - channel.setup(NS_CLIENT, remote, 7); - channel.reliablePending.add(new StringCmdMessage("new")); + void unavailableSenderDoesNotAdvanceOrLoseStagedMessages() { + boolean[] available = {false}; + var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), + (to, bytes) -> available[0] ? client.send(to, bytes) + : new SendResult.Failed(new java.io.IOException("unavailable")), () -> (long) Globals.curtime); + channel.setup(new netadr_t(), 7); + channel.queueReliable(new StringCmdMessage("new")); Globals.curtime = 100; assertFalse(channel.transmit(null)); - assertEquals(1, channel.outgoing_sequence); - assertEquals(1, channel.reliablePending.size()); - assertEquals(0, channel.last_sent_size); - assertEquals(0, channel.last_sent); - assertEquals(0, channel.reliable_sent_flag); + assertEquals(1, channel.getOutgoingSequence()); + assertEquals(1, channel.getQueuedReliable().size()); + assertEquals(0, channel.getLastSentSize()); + assertEquals(0, channel.getLastSentMillis()); + assertEquals(0, channel.getReliableSentFlag()); - channel.remote_address = new netadr_t(); + available[0] = true; assertTrue(channel.transmit(null)); - assertEquals(2, channel.outgoing_sequence); - assertTrue(channel.reliablePending.isEmpty()); - assertEquals(100, channel.last_sent); + assertEquals(2, channel.getOutgoingSequence()); + assertTrue(channel.getQueuedReliable().isEmpty()); + assertEquals(100, channel.getLastSentMillis()); } @Test void unexpectedSendFailureReleasesPreparedTransaction() { - var channel = new netchan_t(client, () -> Globals.curtime); - channel.setup(NS_CLIENT, new netadr_t(), 7); - channel.reliablePending.add(new StringCmdMessage("new")); - channel.remote_address = null; - assertThrows(NullPointerException.class, () -> channel.transmit(null)); - assertEquals(1, channel.outgoing_sequence); - assertEquals(1, channel.reliablePending.size()); - channel.remote_address = new netadr_t(); + boolean[] fail = {true}; + var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), (to, bytes) -> { + if (fail[0]) throw new IllegalStateException("injected send failure"); + return client.send(to, bytes); + }, () -> (long) Globals.curtime); + channel.setup(new netadr_t(), 7); + channel.queueReliable(new StringCmdMessage("new")); + assertThrows(IllegalStateException.class, () -> channel.transmit(null)); + assertEquals(1, channel.getOutgoingSequence()); + assertEquals(1, channel.getQueuedReliable().size()); + fail[0] = false; assertTrue(channel.transmit(null)); } @Test void recordsSentPacketSizeAfterPacketIsBuilt() { - netchan_t netchan = new netchan_t(server, () -> Globals.curtime); - netchan.setup(NS_SERVER, new netadr_t(), 0); + ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); + netchan.setup(new netadr_t(), 0); Globals.curtime = 100; netchan.transmit(null); - assertTrue(netchan.last_sent_size >= 8, "header-only packet should include sequence and ack"); + assertTrue(netchan.getLastSentSize() >= 8, "header-only packet should include sequence and ack"); } @Test void smoothsPingFromAcknowledgedPacketSequence() { - netchan_t netchan = new netchan_t(server, () -> Globals.curtime); - netchan.setup(NS_SERVER, new netadr_t(), 0); + ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); + netchan.setup(new netadr_t(), 0); Globals.curtime = 100; netchan.transmit(null); // sequence 1 Globals.curtime = 160; assertTrue(netchan.accept(packet(1, 1))); - assertEquals(60, netchan.smoothed_ping_ms); + assertEquals(60, netchan.getSmoothedPingMillis()); Globals.curtime = 200; netchan.transmit(null); // sequence 2 Globals.curtime = 300; assertTrue(netchan.accept(packet(2, 2))); - assertEquals(70, netchan.smoothed_ping_ms); + assertEquals(70, netchan.getSmoothedPingMillis()); } private NetworkPacket packet(int sequence, int sequenceAck) { diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt new file mode 100644 index 00000000..c694ad0f --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt @@ -0,0 +1,135 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +import jake2.qcommon.network.ConnectionChannel +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.client.StringCmdMessage +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException + +class ConnectionChannelTest { + @Test + fun `keepalive uses elapsed long clock and only successful sends restart it`() { + val link = LoopbackLink() + var now = Int.MAX_VALUE.toLong() + 100_000 + var result: SendResult = SendResult.Sent + val channel = ConnectionChannel(ChannelRole.CLIENT, link.second.localAddress, { _, _ -> result }) { now } + channel.setup(netadr_t(), 42) + assertFalse(channel.hasBeenIdleFor(1000)) + now += 1000 + assertFalse(channel.hasBeenIdleFor(1000)) + now++ + assertTrue(channel.hasBeenIdleFor(1000)) + assertTrue(channel.transmit(null)) + assertEquals(now, channel.lastSentMillis) + assertFalse(channel.hasBeenIdleFor(1000)) + now += 1001 + result = SendResult.WouldBlock + assertFalse(channel.transmit(null)) + assertTrue(channel.hasBeenIdleFor(1000)) + result = SendResult.Sent + assertTrue(channel.transmit(null)) + assertFalse(channel.hasBeenIdleFor(1000)) + now += 1001 + channel.clear() + assertFalse(channel.hasBeenIdleFor(1000)) + assertEquals(0L, channel.lastSentMillis) + } + + @Test + fun `address input and views are detached and NAT updates affect only the owned port`() { + val link = LoopbackLink() + val destinations = mutableListOf() + val channel = ConnectionChannel(ChannelRole.SERVER, link.first.localAddress, { to, _ -> + destinations += to; SendResult.Sent + }) { 0L } + val address = netadr_t().apply { type = NetAddrType.NA_IP; port = 27910; ip = byteArrayOf(10, 1, 2, 3) } + channel.setup(address, 1234) + address.ip.fill(99); address.port = 1 + val view = channel.remoteAddress + view.ip.fill(88); view.port = 2; view.type = NetAddrType.NA_LOOPBACK + channel.transmit(null) + channel.updateRemotePort(27911) + channel.transmit(null) + assertEquals(listOf(27910, 27911), destinations.map { (it as NetworkAddress.Udp).port }) + assertTrue(destinations.all { (it as NetworkAddress.Udp).ip == IpAddress.fromBytes(byteArrayOf(10, 1, 2, 3)) }) + assertEquals(1234, channel.qport) + } + + @Test + fun `staging snapshots are immutable and cancellation cannot erase transmitted reliable bytes`() { + val link = LoopbackLink() + val wire = mutableListOf() + val channel = ConnectionChannel(ChannelRole.CLIENT, link.second.localAddress, { _, bytes -> + wire += bytes; SendResult.Sent + }) { 0L } + channel.setup(netadr_t(), 42) + val command = StringCmdMessage("first") + channel.queueReliable(command) + val snapshot = channel.queuedReliable + assertThrows(UnsupportedOperationException::class.java) { (snapshot as MutableList).clear() } + assertTrue(channel.transmit(null)) + assertEquals(listOf(command), snapshot) + assertFalse(channel.hasQueuedReliable) + assertFalse(channel.canSendReliable) + channel.queueReliable(StringCmdMessage("cancelled")) + channel.clearQueuedReliable() + assertFalse(channel.hasQueuedReliable) + assertFalse(channel.canSendReliable) + // A higher sequence acknowledgement with the wrong reliable bit requests retransmission. + repeat(2) { assertTrue(channel.transmit(null)) } + val ack = PacketCodec.encodeSequenced(1, 3, null, PacketBytes.copyOf(byteArrayOf())) + val decoded = PacketCodec.decode(ack, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded + assertTrue(channel.accept(jake2.qcommon.network.messages.NetworkPacket.fromEnvelope(link.second.localAddress, decoded.envelope))) + assertTrue(channel.needsReliable) + assertTrue(channel.transmit(null)) + val resent = (PacketCodec.decode(wire.last(), PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope as PacketEnvelope.Sequenced + val original = (PacketCodec.decode(wire.first(), PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope as PacketEnvelope.Sequenced + assertArrayEquals(original.bytes.toByteArray().copyOfRange(10, original.bytes.size), resent.bytes.toByteArray().copyOfRange(10, resent.bytes.size)) + } + + @Test + fun `would block and IO failure retain exact bytes sequence and staging for retry`() { + val link = LoopbackLink() + for (failure in listOf(SendResult.WouldBlock, SendResult.Failed(IOException("unavailable")))) { + val attempts = mutableListOf() + val channel = ConnectionChannel(ChannelRole.CLIENT, link.second.localAddress, { _, bytes -> + attempts += bytes + if (attempts.size == 1) failure else SendResult.Sent + }) { 100L } + channel.setup(netadr_t(), 42) + channel.queueReliable(StringCmdMessage("retry")) + assertFalse(channel.transmit(null)) + assertEquals(1, channel.outgoingSequence) + assertTrue(channel.hasQueuedReliable) + assertTrue(channel.transmit(null)) + assertEquals(attempts[0], attempts[1]) + assertFalse(channel.hasQueuedReliable) + assertEquals(2, channel.outgoingSequence) + } + } + + @Test + fun `overflow marks only the connection fatal and clear retains its server role and sender`() { + val link = LoopbackLink() + val wire = mutableListOf() + val channel = ConnectionChannel(ChannelRole.SERVER, link.first.localAddress, { _, bytes -> + wire += bytes; SendResult.Sent + }) { 0L } + channel.queueReliable(StringCmdMessage("x".repeat(Defines.MAX_MSGLEN))) + assertFalse(channel.transmit(null)) + assertTrue(channel.fatalError) + assertTrue(wire.isEmpty()) + channel.clear() + assertFalse(channel.fatalError) + assertFalse(channel.hasQueuedReliable) + channel.setup(netadr_t(), 42) + assertTrue(channel.transmit(null)) + assertEquals(8, wire.single().size, "A server channel retains its header role across clear/setup") + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt index 3f684c13..79ea4436 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -2,7 +2,7 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Defines import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.messages.client.MoveMessage @@ -17,13 +17,13 @@ class OwnedChannelTest { fun `the same immutable movement uses each packet's reserved sequence after reliable data`() { Session().use { session -> val movement = MoveMessage(3, UserCommand(), UserCommand(), UserCommand(msec = 16, forwardmove = 120)) - session.client.reliablePending.add(StringCmdMessage("new")) + session.client.queueReliable(StringCmdMessage("new")) repeat(2) { index -> assertTrue(session.client.transmit(listOf(movement))) val packet = session.receive(session.serverTransport, PacketDirection.FROM_CLIENT) assertTrue(session.server.accept(packet)) - assertEquals(index + 1, session.server.incoming_sequence) - val body = packet.parseBodyFromClient(session.server.incoming_sequence) as MessageDecodeResult.Decoded + assertEquals(index + 1, session.server.incomingSequence) + val body = packet.parseBodyFromClient(session.server.incomingSequence) as MessageDecodeResult.Decoded val decoded = body.messages.last() as MoveMessage assertTrue(decoded.checksumValid) assertEquals(movement, decoded) @@ -35,12 +35,12 @@ class OwnedChannelTest { private val link = LoopbackLink() val clientTransport = NetworkTransport(link.first, link.second.localAddress) val serverTransport = NetworkTransport(link.second, link.first.localAddress) - val client = netchan_t(clientTransport) { 100L } - val server = netchan_t(serverTransport) { 100L } + val client = ConnectionChannel(ChannelRole.CLIENT, clientTransport.localPeer, clientTransport::send) { 100L } + val server = ConnectionChannel(ChannelRole.SERVER, serverTransport.localPeer, serverTransport::send) { 100L } init { - client.setup(Defines.NS_CLIENT, netadr_t(), 42) - server.setup(Defines.NS_SERVER, netadr_t(), 42) + client.setup(netadr_t(), 42) + server.setup(netadr_t(), 42) } fun receive(transport: NetworkTransport, direction: PacketDirection): NetworkPacket { @@ -58,8 +58,8 @@ class OwnedChannelTest { @Test fun `two channel pairs deliver and acknowledge independently without global transport`() { Session().use { first -> Session().use { second -> - first.client.reliablePending.add(StringCmdMessage("first")) - second.client.reliablePending.add(StringCmdMessage("second")) + first.client.queueReliable(StringCmdMessage("first")) + second.client.queueReliable(StringCmdMessage("second")) assertTrue(first.client.transmit(null)) assertTrue(second.client.transmit(null)) val firstPacket = first.receive(first.serverTransport, PacketDirection.FROM_CLIENT) @@ -70,12 +70,12 @@ class OwnedChannelTest { assertEquals("second", ((secondPacket.parseBodyFromClient(1) as MessageDecodeResult.Decoded).messages.single() as StringCmdMessage).command) assertTrue(first.server.transmit(null)) assertTrue(first.client.accept(first.receive(first.clientTransport, PacketDirection.FROM_SERVER))) - assertTrue(first.client.canReliable()) - assertFalse(second.client.canReliable()) + assertTrue(first.client.canSendReliable) + assertFalse(second.client.canSendReliable) first.close() assertTrue(second.server.transmit(null)) assertTrue(second.client.accept(second.receive(second.clientTransport, PacketDirection.FROM_SERVER))) - assertTrue(second.client.canReliable()) + assertTrue(second.client.canSendReliable) } } } @@ -83,14 +83,14 @@ class OwnedChannelTest { fun `clearing a channel retains its borrowed transport and failed send retains staging`() { Session().use { session -> session.client.clear() - session.client.setup(Defines.NS_CLIENT, netadr_t(), 99) + session.client.setup(netadr_t(), 99) assertTrue(session.client.transmit(null)) assertEquals(99, session.receive(session.serverTransport, PacketDirection.FROM_CLIENT).qport) session.clientTransport.close() - session.client.reliablePending.add(StringCmdMessage("retry")) + session.client.queueReliable(StringCmdMessage("retry")) assertFalse(session.client.transmit(null)) - assertEquals(2, session.client.outgoing_sequence) - assertEquals(1, session.client.reliablePending.size) + assertEquals(2, session.client.outgoingSequence) + assertEquals(1, session.client.queuedReliable.size) } } } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt index b68a70d2..47cab631 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -64,14 +64,14 @@ class PacketNetworkTest { PacketNetwork(clientTransport, NetworkRole.CLIENT).use { client -> PacketNetwork(serverTransport, NetworkRole.SERVER).use { server -> val clientChannel = client.newChannel() - clientChannel.setup(Defines.NS_CLIENT, netadr_t(), 1234) + clientChannel.setup(netadr_t(), 1234) assertTrue(clientChannel.transmit(null)) val request = server.receive()!! assertEquals(1234, request.qport) assertEquals(10, request.buffer.readcount) val serverChannel = server.newChannel() - serverChannel.setup(Defines.NS_SERVER, netadr_t(), 1234) + serverChannel.setup(netadr_t(), 1234) assertTrue(serverChannel.accept(request)) assertTrue(serverChannel.transmit(null)) val reply = client.receive()!! diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index 75f1579e..c1ba9ef5 100644 --- a/server/src/main/java/jake2/server/ClientNetworkInfo.java +++ b/server/src/main/java/jake2/server/ClientNetworkInfo.java @@ -25,7 +25,7 @@ of the License, or (at your option) any later version. import jake2.qcommon.Defines; import jake2.qcommon.ServerEntity; import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.netchan_t; +import jake2.qcommon.network.ConnectionChannel; import jake2.qcommon.network.messages.server.PrintMessage; import jake2.qcommon.usercmd_t; @@ -38,7 +38,7 @@ of the License, or (at your option) any later version. */ public class ClientNetworkInfo { - public ClientNetworkInfo(netchan_t channel) { + public ClientNetworkInfo(ConnectionChannel channel) { this.netchan = java.util.Objects.requireNonNull(channel); for (int n = 0; n < Defines.UPDATE_BACKUP; n++) { frames[n] = new client_frame_t(); @@ -91,12 +91,12 @@ public ClientNetworkInfo(netchan_t channel) { int challenge; // challenge of this user, randomly generated - netchan_t netchan; + ConnectionChannel netchan; /** Queue text only when it meets this connection's requested message level. */ void queuePrint(int level, String text) { if (level >= messagelevel) { - netchan.reliablePending.add(new PrintMessage(level, text)); + netchan.queueReliable(new PrintMessage(level, text)); } } } diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 9f470804..23ff7f8e 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -894,7 +894,7 @@ void SV_BroadcastCommand(String s) { for (ClientNetworkInfo client : serverMain.getClients()) { if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) continue; - client.netchan.reliablePending.add(StuffTextMessage.command(s)); + client.netchan.queueReliable(StuffTextMessage.command(s)); } } @@ -1320,7 +1320,7 @@ private void SV_Multicast(float[] origin, MulticastTypes to, NetworkMessage msg) } if (reliable) { - client.netchan.reliablePending.add(msg); + client.netchan.queueReliable(msg); } else client.unreliable.add(msg); } @@ -1341,7 +1341,7 @@ public void unicastMessage(int index, ServerMessage msg, boolean reliable) { ClientNetworkInfo client = serverMain.getClients().get(index - 1); if (reliable) { - client.netchan.reliablePending.add(msg); + client.netchan.queueReliable(msg); } else client.unreliable.add(msg); diff --git a/server/src/main/kotlin/jake2/server/ServerConnections.kt b/server/src/main/kotlin/jake2/server/ServerConnections.kt index 9660b196..9bc2e0c1 100644 --- a/server/src/main/kotlin/jake2/server/ServerConnections.kt +++ b/server/src/main/kotlin/jake2/server/ServerConnections.kt @@ -66,8 +66,8 @@ class ServerConnections( val key = PeerKey.of(from) for ((index, client) in clients.withIndex()) { if (client.state == ClientStates.CS_FREE) continue - if (PeerKey.of(client.netchan.remote_address) != key) continue - if (client.netchan.qport != qport && client.netchan.remote_address.port != from.port) continue + if (PeerKey.of(client.netchan.remoteAddress) != key) continue + if (client.netchan.qport != qport && client.netchan.remoteAddress.port != from.port) continue if (from.type != NetAddrType.NA_LOOPBACK && nowMillis - client.lastconnect < reconnectDelayMillis) return Admission.TooSoon return Admission.Slot(index, reconnect = true) @@ -82,11 +82,11 @@ class ServerConnections( val key = PeerKey.of(packet.from) val client = clients.firstOrNull { it.state != ClientStates.CS_FREE && it.netchan.qport == packet.qport && - PeerKey.of(it.netchan.remote_address) == key + PeerKey.of(it.netchan.remoteAddress) == key } ?: return null if (!client.netchan.accept(packet)) return null - val changed = client.netchan.remote_address.port != packet.from.port - if (changed) client.netchan.remote_address.port = packet.from.port + val changed = client.netchan.remoteAddress.port != packet.from.port + if (changed) client.netchan.updateRemotePort(packet.from.port) return Routed(client, changed) } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index cc7b8b24..5d92a168 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -355,7 +355,7 @@ class ServerHost @JvmOverloads constructor( client.gameName = null client.edict = null client.download = null - client.netchan.reliablePending.clear() + client.netchan.clearQueuedReliable() client.unreliable.clear() } pendingContinuations.remove(game) @@ -684,7 +684,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) if (client.state == ClientStates.CS_SPAWNED) client.state = ClientStates.CS_CONNECTED client.lastReceivedFrame = -1 if (client.state == ClientStates.CS_CONNECTED) { - client.netchan.reliablePending.add(StuffTextMessage.command("changing")) + client.netchan.queueReliable(StuffTextMessage.command("changing")) } } @@ -705,7 +705,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) for (client in transitionClients) { if (client.state == ClientStates.CS_CONNECTED) { - client.netchan.reliablePending.add(StuffTextMessage.command("reconnect")) + client.netchan.queueReliable(StuffTextMessage.command("reconnect")) } } return gameImports @@ -1059,7 +1059,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) network.sendConnectionless(adr, ConnectionlessCommand.client_connect, "") - client.netchan.setup(Defines.NS_SERVER, adr, qport) + client.netchan.setup(adr, qport) client.state = ClientStates.CS_CONNECTED @@ -1127,7 +1127,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) fun dropClient(client: ClientNetworkInfo) { if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) return try { - client.netchan.reliablePending.add(DisconnectMessage) + client.netchan.queueReliable(DisconnectMessage) if (client.state == ClientStates.CS_SPAWNED) { games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) } @@ -1162,7 +1162,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) val client = routed.client if (client.state != ClientStates.CS_ZOMBIE) { client.lastmessage = realtime - val body = networkPacket.parseBodyFromClient(client.netchan.incoming_sequence) + val body = networkPacket.parseBodyFromClient(client.netchan.incomingSequence) when (body) { is MessageDecodeResult.Rejected -> { Com.Printf("Bad client payload at ${body.offset}: ${body.detail}\n") @@ -1349,7 +1349,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.edict.index - 1 } - client.netchan.reliablePending.add( + client.netchan.queueReliable( ServerDataMessage.forClient( gameImports.spawncount, gameImports.isDemo, @@ -1369,7 +1369,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.lastcmd = usercmd_t() // begin fetching configstrings - client.netchan.reliablePending.add( + client.netchan.queueReliable( StuffTextMessage.command( String.format( "cmd %s %d 0", @@ -1405,12 +1405,12 @@ Resets server to host a given map, moves all clients to it (via reconnect) // write a packet full of data var currentReliableSize = - client.netchan.reliablePending.sumOf { it.size } + client.netchan.queuedReliableBytes while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { if (gameImports.configstrings[start] != null && gameImports.configstrings[start].isNotEmpty()) { val config = ConfigStringMessage(start, gameImports.configstrings[start]) currentReliableSize += config.size - client.netchan.reliablePending.add(config) + client.netchan.queueReliable(config) } start++ } @@ -1421,7 +1421,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } else { String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) } - client.netchan.reliablePending.add(StuffTextMessage.command(nextCmd)) + client.netchan.queueReliable(StuffTextMessage.command(nextCmd)) } private fun sendBaselines(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1445,13 +1445,13 @@ Resets server to host a given map, moves all clients to it (via reconnect) // write a packet full of data var currentReliableSize = - client.netchan.reliablePending.sumOf { it.size } + client.netchan.queuedReliableBytes while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { val base = gameImports.baselines[start] if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { val spawn = SpawnBaselineMessage(base) currentReliableSize += spawn.size - client.netchan.reliablePending.add(spawn) + client.netchan.queueReliable(spawn) } start++ } @@ -1464,7 +1464,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // continue from where we finished String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) } - client.netchan.reliablePending.add(StuffTextMessage.command(nextCmd)) + client.netchan.queueReliable(StuffTextMessage.command(nextCmd)) } private fun beginClient(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1560,7 +1560,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) val data = ByteArray(packet) System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet) - client.netchan.reliablePending.add(DownloadMessage.chunk(data, percent)) + client.netchan.queueReliable(DownloadMessage.chunk(data, percent)) if (client.downloadcount == client.downloadsize) { client.download = null @@ -1601,7 +1601,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // refuse - client.netchan.reliablePending.add(DownloadMessage.Failure()) + client.netchan.queueReliable(DownloadMessage.Failure()) return } @@ -1610,7 +1610,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // The client waits for either data or an explicit refusal. Silently // returning here leaves precache stuck waiting for a download forever. if (client.download == null) { - client.netchan.reliablePending.add(DownloadMessage.Failure()) + client.netchan.queueReliable(DownloadMessage.Failure()) return } @@ -1627,7 +1627,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // refuse - client.netchan.reliablePending.add(DownloadMessage.Failure()) + client.netchan.queueReliable(DownloadMessage.Failure()) return } @@ -1649,9 +1649,9 @@ Resets server to host a given map, moves all clients to it (via reconnect) // if the reliable message overflowed, // drop the client val pendingReliableSize = - c.netchan.reliablePending.sumOf { it.size } + c.netchan.queuedReliableBytes if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { - c.netchan.reliablePending.clear() + c.netchan.clearQueuedReliable() c.unreliable.clear() broadcastPrint(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) dropClient(c) @@ -1667,7 +1667,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) sendClientDatagram(c, game) } else { // just update reliable if needed - if (c.netchan.reliablePending.isNotEmpty() || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( + if (c.netchan.hasQueuedReliable || c.netchan.hasBeenIdleFor(1000)) c.netchan.transmit( null ) } @@ -1687,7 +1687,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) for (cl in getClientsForInstance(gameName)) { if (cl == null || level < cl.messagelevel) continue if (cl.state != ClientStates.CS_SPAWNED) continue - cl.netchan.reliablePending.add(PrintMessage(level, message.orEmpty())) + cl.netchan.queueReliable(PrintMessage(level, message.orEmpty())) } } @@ -1701,7 +1701,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) fun shouldRateLimit(c: ClientNetworkInfo): Boolean { // never drop over the loopback - if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) return false + if (c.netchan.remoteAddress.type == NetAddrType.NA_LOOPBACK) return false var total = 0 @@ -1815,7 +1815,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) Com.Printf(cl.name.padEnd(16)) val lastMessageAge = realtime - cl.lastmessage Com.Printf(lastMessageAge.toString().padStart(7) + " ") - Com.Printf(cl.netchan.remote_address.toString().padEnd(22)) + Com.Printf(cl.netchan.remoteAddress.toString().padEnd(22)) Com.Printf(cl.netchan.qport.toString().padStart(5)) Com.Printf("\n") diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index c9642dc7..7e4e7ae2 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -11,7 +11,7 @@ import jake2.qcommon.network.transport.LocalSide /** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ServerNetwork : AutoCloseable { fun configure(multiplayer: Boolean): Boolean - fun newChannel(): netchan_t + fun newChannel(): ConnectionChannel fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) fun stop() diff --git a/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt index a02f4af7..f26ac059 100644 --- a/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt @@ -3,7 +3,7 @@ package jake2.server import jake2.qcommon.Defines import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.netchan_t +import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.protocol.* import jake2.qcommon.network.runtime.NetworkTransport @@ -42,9 +42,9 @@ class ServerConnectionsTest { @Test fun `reconnect uses actual slot index after a free slot and respects Host time boundary`() { NetworkTransport.standalone(LocalSide.SECOND).use { transport -> - fun client(state: ClientStates) = ClientNetworkInfo(netchan_t(transport) { 0L }).apply { + fun client(state: ClientStates) = ClientNetworkInfo(ConnectionChannel(ChannelRole.SERVER, transport.localPeer, transport::send) { 0L }).apply { this.state = state - netchan.setup(Defines.NS_SERVER, address(), 7) + netchan.setup(address(), 7) lastconnect = 100 } val free = client(ClientStates.CS_FREE) @@ -62,7 +62,7 @@ class ServerConnectionsTest { connections.selectSlot(clients, address(2), 7, 600, 500f)) assertEquals(ServerConnections.Admission.Full, connections.selectSlot(listOf(occupied), address(2), 7, 600, 500f)) - occupied.netchan.setup(Defines.NS_SERVER, netadr_t(), 7) + occupied.netchan.setup(netadr_t(), 7) assertEquals(ServerConnections.Admission.Slot(1, true), connections.selectSlot(clients, netadr_t(), 7, 100, 500f)) } @@ -78,27 +78,27 @@ class ServerConnectionsTest { @Test fun `routing rejects wrong peers and stale packets before committing a translated port`() { NetworkTransport.standalone(LocalSide.SECOND).use { transport -> - val client = ClientNetworkInfo(netchan_t(transport) { 0L }).apply { + val client = ClientNetworkInfo(ConnectionChannel(ChannelRole.SERVER, transport.localPeer, transport::send) { 0L }).apply { state = ClientStates.CS_CONNECTED - netchan.setup(Defines.NS_SERVER, address(), 7) + netchan.setup(address(), 7) } val connections = ServerConnections() val clients = listOf(client) assertNull(connections.route(packet(1, 20000, qport = 8), clients)) assertNull(connections.route(packet(1, 20000, host = 2), clients)) - assertEquals(10000, client.netchan.remote_address.port) + assertEquals(10000, client.netchan.remoteAddress.port) assertEquals(ServerConnections.Routed(client, true), connections.route(packet(2, 20000), clients)) - assertEquals(20000, client.netchan.remote_address.port) + assertEquals(20000, client.netchan.remoteAddress.port) assertNull(connections.route(packet(1, 30000), clients)) assertNull(connections.route(packet(2, 30000), clients)) - assertEquals(20000, client.netchan.remote_address.port) - assertEquals(2, client.netchan.incoming_sequence) + assertEquals(20000, client.netchan.remoteAddress.port) + assertEquals(2, client.netchan.incomingSequence) assertEquals(ServerConnections.Routed(client, false), connections.route(packet(3, 20000), clients)) client.state = ClientStates.CS_ZOMBIE assertNotNull(connections.route(packet(4, 20000), clients)) client.state = ClientStates.CS_FREE assertNull(connections.route(packet(5, 40000), clients)) - assertEquals(20000, client.netchan.remote_address.port) + assertEquals(20000, client.netchan.remoteAddress.port) } } } diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt index cb28d22c..469282b2 100644 --- a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -54,7 +54,7 @@ class ServerNetworkTest { fun connection(qport: Int) = ClientNetworkInfo(peer.network.newChannel()).apply { state = ClientStates.CS_CONNECTED userinfo = "before" - netchan.setup(Defines.NS_SERVER, netadr_t(), qport) + netchan.setup(netadr_t(), qport) } val bad = connection(7) val healthy = connection(8) @@ -68,9 +68,9 @@ class ServerNetworkTest { peer.host.readPackets() assertEquals(ClientStates.CS_ZOMBIE, bad.state) assertEquals("before", bad.userinfo) - assertTrue(bad.netchan.reliablePending.any { it is DisconnectMessage }) + assertTrue(bad.netchan.queuedReliable.any { it is DisconnectMessage }) assertEquals(ClientStates.CS_CONNECTED, healthy.state) - assertTrue(healthy.netchan.reliablePending.isEmpty()) + assertTrue(healthy.netchan.queuedReliable.isEmpty()) } } finally { Cvar.getInstance().clear() From 76b6cdc11705b22373e8df37d83b47f7bfc2406f Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 08:40:43 +0100 Subject: [PATCH 49/57] Replace legacy network addresses with immutable values --- .../jake2/server/GameContinuationTest.kt | 4 +- .../jake2/server/HostClientAccountingTest.kt | 28 +--- .../server/HostSessionPreparationTest.kt | 2 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 22 ++- .../demoth/cake/network/ClientHandshake.kt | 35 ++--- .../org/demoth/cake/network/ClientNetwork.kt | 9 +- .../org/demoth/cake/ui/menu/MenuController.kt | 8 +- .../cake/network/ClientHandshakeTest.kt | 62 ++++---- .../demoth/cake/network/ClientNetworkTest.kt | 5 +- .../jake2/qcommon/network/NetAddrType.java | 9 -- .../network/messages/NetworkPacket.java | 16 +- .../java/jake2/qcommon/network/netadr_t.java | 142 ------------------ .../qcommon/network/ConnectionChannel.kt | 17 ++- .../qcommon/network/runtime/PacketNetwork.kt | 10 +- .../network/transport/AddressResolver.kt | 91 +++++++++++ .../network/transport/NetworkAddress.kt | 7 + .../qcommon/network/NetAddressBridgeTest.java | 41 ----- .../qcommon/network/NetPacketReceiveTest.java | 6 +- .../network/NetSocketOwnershipTest.java | 6 +- .../jake2/qcommon/network/NetchanTest.java | 10 +- .../kotlin/jake2/qcommon/OutputFailureTest.kt | 13 +- .../network/protocol/PacketCodecTest.kt | 2 +- .../network/runtime/AddressResolverTest.kt | 80 ++++++++++ .../network/runtime/ConnectionChannelTest.kt | 16 +- .../network/runtime/OwnedChannelTest.kt | 7 +- .../network/runtime/PacketNetworkTest.kt | 9 +- .../kotlin/jake2/server/ServerConnections.kt | 32 ++-- .../main/kotlin/jake2/server/ServerHost.kt | 13 +- .../main/kotlin/jake2/server/ServerNetwork.kt | 9 +- .../jake2/server/ServerConnectionsTest.kt | 57 ++++--- .../kotlin/jake2/server/ServerNetworkTest.kt | 3 +- 31 files changed, 383 insertions(+), 388 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/NetAddrType.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/netadr_t.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/transport/AddressResolver.kt delete mode 100644 qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/AddressResolverTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index a41f522d..75703040 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -5,7 +5,7 @@ import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import jake2.qcommon.network.Netchan -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.messages.server.StuffTextMessage import jake2.qcommon.vfs.* import org.junit.jupiter.api.* @@ -64,7 +64,7 @@ class GameContinuationTest { gameName = game.name edict = game.gameExports.getEdict(index + 1) state = ClientStates.CS_CONNECTED - netchan.setup(netadr_t(), index) + netchan.setup(netchan.remoteAddress, index) } private fun finish(client: ClientNetworkInfo, game: GameImportsImpl) = diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 02d3ee2f..20cdfa42 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -1,5 +1,7 @@ package jake2.server +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.transport.IpAddress import jake2.game.GameEntity import jake2.game.GamePlayerInfo import jake2.qcommon.* @@ -88,7 +90,7 @@ class HostClientAccountingTest { lastconnect = 100 rate = 100000 state = ClientStates.CS_CONNECTED - netchan.setup(netadr_t(), index) + netchan.setup(netchan.remoteAddress, index) } @AfterEach @@ -315,7 +317,7 @@ class HostClientAccountingTest { fun `rate history expires by Host frames and movement budget ignores Game age`() { val a = bind(0, first) val b = bind(1, second) - a.netchan.setup(netadr_t().apply { type = NetAddrType.NA_IP }, a.netchan.qport) + a.netchan.setup(NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), 0), a.netchan.qport) a.rate = 1 a.message_size[1] = 100 repeat(10) { @@ -345,11 +347,7 @@ class HostClientAccountingTest { receiver.receive(packet) return String(packet.data, 4, packet.length - 4).trim() } - val address = netadr_t().apply { - type = NetAddrType.NA_IP - ip = loopback.address - port = receiver.localPort - } + val address = NetworkAddress.Udp(IpAddress.fromBytes(loopback.address), receiver.localPort) cvars.find("port").forceSet(0) try { assertTrue(network.server.configure(true)) @@ -361,20 +359,14 @@ class HostClientAccountingTest { // No UDP socket while filling synthetic remote addresses: no external packets. Globals.curtime = -1000 repeat(Defines.MAX_CHALLENGES - 1) { index -> - val remote = netadr_t().apply { - type = NetAddrType.NA_IP - ip = byteArrayOf(192.toByte(), 0, (index / 256).toByte(), (index % 256).toByte()) - } + val remote = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(192.toByte(), 0, (index / 256).toByte(), (index % 256).toByte())), 0) host.handleConnectionlessPacket(remote, "getchallenge") } assertTrue(network.server.configure(true)) host.handleConnectionlessPacket(address, "getchallenge") assertEquals(issued, reply(), "Repeated requests retain the existing challenge") network.server.configure(false) - host.handleConnectionlessPacket(netadr_t().apply { - type = NetAddrType.NA_IP - ip = byteArrayOf(198.toByte(), 51, 100, 1) - }, "getchallenge") + host.handleConnectionlessPacket(NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(198.toByte(), 51, 100, 1)), 0), "getchallenge") assertTrue(network.server.configure(true)) host.handleConnectionlessPacket(address, "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester") @@ -430,11 +422,7 @@ class HostClientAccountingTest { val loopback = java.net.InetAddress.getByName("127.0.0.1") java.net.DatagramSocket(0, loopback).use { receiver -> receiver.soTimeout = 2000 - val address = netadr_t().apply { - type = NetAddrType.NA_IP - ip = loopback.address - port = receiver.localPort - } + val address = NetworkAddress.Udp(IpAddress.fromBytes(loopback.address), receiver.localPort) host.handleConnectionlessPacket(address, "getchallenge") val response = java.net.DatagramPacket(ByteArray(2048), 2048) receiver.receive(response) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index a6ab050f..be9286c7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -501,7 +501,7 @@ class HostSessionPreparationTest { val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") val client = host.clients[0] client.state = ClientStates.CS_CONNECTED - client.netchan.setup(jake2.qcommon.network.netadr_t(), 0) + client.netchan.setup(client.netchan.remoteAddress, 0) network.sendLocal(Defines.NS_CLIENT, 8, ByteArray(8)) cvars.find("paused").forceSet(true) var deferredRan = false 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 9cd1513b..0c4b64df 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -25,7 +25,6 @@ import jake2.qcommon.exec.find import org.demoth.cake.network.ClientNetwork import org.demoth.cake.network.ClientHandshake import jake2.qcommon.network.protocol.MessageDecodeResult -import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage @@ -33,7 +32,7 @@ import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.messages.client.UserInfoMessage import jake2.qcommon.network.messages.server.* -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.vfs.VfsDebugCommands import ktx.app.KtxApplicationAdapter import ktx.app.KtxInputAdapter @@ -306,7 +305,7 @@ class Cake( } else { val target = handshake.lastTarget ?: return@AddCommand - val local = target.type == NetAddrType.NA_LOOPBACK + val local = target is NetworkAddress.Local if (local && host.state != HostState.RUNNING) { Com.Warn("No local server is running.\n") return@AddCommand @@ -323,12 +322,12 @@ class Cake( Com.Printf("usage: connect \n") return@AddCommand } - val target = netadr_t.fromString(server, PORT_SERVER) + val target = network.resolveAddress(server, PORT_SERVER) if (target == null) { Com.Warn("Bad server address\n") return@AddCommand } - if (target.type == NetAddrType.NA_LOOPBACK) { + if (target is NetworkAddress.Local) { if (host.state != HostState.RUNNING) { Com.Warn("No local server is running. Use map to start a local game or connect to a UDP address.\n") return@AddCommand @@ -371,13 +370,13 @@ class Cake( Com.Printf("You must either be connected,\nor set the 'rcon_address' cvar\nto issue rcon commands\n") return@AddCommand } - netadr_t.fromString(rconAddress, PORT_SERVER) ?: run { + network.resolveAddress(rconAddress, PORT_SERVER) ?: run { Com.Warn("Bad rcon_address\n") return@AddCommand } } - if (to.type != NetAddrType.NA_LOOPBACK && !network.configure(true)) { + if (to !is NetworkAddress.Local && !network.configure(true)) { Com.Warn("Could not open the client UDP socket\n") return@AddCommand } @@ -494,8 +493,8 @@ class Cake( return result } - private fun beginConnection(target: netadr_t, local: Boolean) { - if (!handshake.begin(target, local)) { + private fun beginConnection(target: NetworkAddress, local: Boolean) { + if (!handshake.begin(target)) { Com.Warn("Could not open the client UDP socket\n") return } @@ -677,7 +676,7 @@ class Cake( // Existing local transitions and remote connections retain their handshake paths. if (networkState == DISCONNECTED && host.state == HostState.RUNNING) { awaitingNewGame = false - beginConnection(netadr_t(), local = true) + beginConnection(network.localPeer, local = true) menuVisible = false consoleVisible = false } @@ -1073,8 +1072,7 @@ class Cake( // // packet from server // - if (networkPacket.from.type != netchan.remoteAddress.type || - !networkPacket.from.compareIp(netchan.remoteAddress)) { + if (networkPacket.from != netchan.remoteAddress) { Com.Warn(networkPacket.from.toString() + ": sequenced packet without connection\n") return@poll } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt index c0920e26..e24e8eed 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt @@ -1,26 +1,16 @@ package org.demoth.cake.network -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.messages.ConnectionlessCommand -import jake2.qcommon.network.transport.IpAddress /** Game-thread handshake controller. Time and current connect payload are supplied by the caller. */ class ClientHandshake( private val network: ClientNetwork, private val connectPayload: (challenge: Int) -> String, ) { - private data class Target(val type: NetAddrType, val ip: IpAddress, val port: Int) { - fun toLegacy() = netadr_t().also { it.type = type; it.ip = ip.toByteArray(); it.port = port } - fun matches(from: netadr_t) = type == from.type && port == from.port && ip == IpAddress.fromBytes(from.ip) - companion object { - fun copyOf(address: netadr_t) = Target(address.type, IpAddress.fromBytes(address.ip), address.port) - } - } - - private var target: Target? = null - /** Owned snapshot, retained after reset for the manual reconnect command. */ - val lastTarget: netadr_t? get() = target?.toLegacy() + private var target: NetworkAddress? = null + /** Immutable target, retained after reset for the manual reconnect command. */ + val lastTarget: NetworkAddress? get() = target var isLocal = false private set private var pending = false @@ -28,9 +18,10 @@ class ClientHandshake( private var retryRemainingSeconds = 0f /** A failed bind leaves the current handshake untouched. */ - fun begin(address: netadr_t, local: Boolean): Boolean { + fun begin(address: NetworkAddress): Boolean { + val local = address is NetworkAddress.Local if (!network.configure(!local)) return false - target = Target.copyOf(address) + target = address isLocal = local challenge = 0 restart() @@ -59,14 +50,14 @@ class ClientHandshake( val peer = target ?: return if (retryRemainingSeconds <= 0f) { if (isLocal) sendConnect(peer) - else network.sendConnectionless(peer.toLegacy(), ConnectionlessCommand.getchallenge, "\n") + else network.sendConnectionless(peer, ConnectionlessCommand.getchallenge, "\n") retryRemainingSeconds = 1f } else { retryRemainingSeconds -= deltaSeconds } } - fun receiveChallenge(from: netadr_t, value: String?): Boolean { + fun receiveChallenge(from: NetworkAddress, value: String?): Boolean { val peer = expected(from) ?: return false val parsed = value?.toIntOrNull() ?: return false challenge = parsed @@ -74,15 +65,15 @@ class ClientHandshake( return true } - fun acceptConnection(from: netadr_t): Boolean { + fun acceptConnection(from: NetworkAddress): Boolean { if (expected(from) == null) return false pending = false return true } - private fun expected(from: netadr_t): Target? = target?.takeIf { pending && it.matches(from) } + private fun expected(from: NetworkAddress): NetworkAddress? = target?.takeIf { pending && it == from } - private fun sendConnect(peer: Target) { - network.sendConnectionless(peer.toLegacy(), ConnectionlessCommand.connect, connectPayload(challenge)) + private fun sendConnect(peer: NetworkAddress) { + network.sendConnectionless(peer, ConnectionlessCommand.connect, connectPayload(challenge)) } } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index ef2dc369..9b495967 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -1,6 +1,8 @@ package org.demoth.cake.network import jake2.qcommon.network.* +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.transport.AddressResolver import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.runtime.NetworkTransport @@ -9,10 +11,12 @@ import jake2.qcommon.network.runtime.NetworkRole /** Client transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ClientNetwork : AutoCloseable { + val localPeer: NetworkAddress.Local + fun resolveAddress(text: String, defaultPort: Int): NetworkAddress? = AddressResolver.resolve(text, defaultPort, localPeer) fun configure(multiplayer: Boolean): Boolean fun newChannel(): ConnectionChannel fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int - fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) + fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) fun stop() override fun close() = stop() } @@ -20,10 +24,11 @@ interface ClientNetwork : AutoCloseable { /** Owns the supplied transport; connections borrow it through the shared packet adapter. */ class TransportClientNetwork(transport: NetworkTransport) : ClientNetwork { private val packets = PacketNetwork(transport, NetworkRole.CLIENT) + override val localPeer: NetworkAddress.Local get() = packets.localPeer override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + override fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) = packets.sendConnectionless(to, command, payload) override fun stop() = packets.stop() override fun close() = packets.close() 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 cb3c9dfd..a7dca6d2 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 @@ -1,7 +1,7 @@ package org.demoth.cake.ui.menu import jake2.qcommon.Defines.PORT_SERVER -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.AddressResolver import org.demoth.cake.hosting.* interface MenuBackend { @@ -338,9 +338,11 @@ class MenuController( val address = if (parsedPort == null) { normalizedHost } else { - "$normalizedHost:$parsedPort" + val host = if (normalizedHost.count { it == ':' } > 1 && !normalizedHost.startsWith("[")) + "[$normalizedHost]" else normalizedHost + "$host:$parsedPort" } - if (netadr_t.fromString(address, PORT_SERVER) == null) { + if (!AddressResolver.isValid(address, PORT_SERVER)) { refreshState( activeScreen = MenuScreen.JOIN_GAME, formOverride = state.profileEditor.form, diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt index 50dda442..8f02d78c 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt @@ -1,7 +1,7 @@ package org.demoth.cake.network -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.IpAddress +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket @@ -9,8 +9,9 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class ClientHandshakeTest { - private data class Sent(val ip: List, val port: Int, val command: ConnectionlessCommand, val payload: String) + private data class Sent(val address: NetworkAddress, val command: ConnectionlessCommand, val payload: String) private class Network : ClientNetwork { + override val localPeer = jake2.qcommon.network.transport.LoopbackLink().second.localAddress val sent = mutableListOf() val binds = mutableListOf() var bindSucceeds = true @@ -20,19 +21,14 @@ class ClientHandshakeTest { } override fun newChannel(): ConnectionChannel = error("Handshake must not create protocol channels") override fun poll(budget: Int, consume: (NetworkPacket) -> Unit): Int = error("Caller owns polling") - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { - sent += Sent(to.ip.toList(), to.port, command, payload) - // Deliberately hostile legacy consumer: it cannot mutate the retained target. - to.ip.fill(0) - to.port = 0 + override fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) { + sent += Sent(to, command, payload) + // An exported byte array cannot mutate the immutable target. + (to as? NetworkAddress.Udp)?.ip?.toByteArray()?.fill(0) } override fun stop() = error("Handshake must not stop its owner's transport") } - private fun remote(port: Int = 27910) = netadr_t().apply { - type = NetAddrType.NA_IP - ip = byteArrayOf(127, 0, 0, 1) - this.port = port - } + private fun remote(port: Int = 27910) = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), port) @Test fun `remote retry uses frame timing and fresh connect payload then stops after acceptance`() { @@ -40,7 +36,7 @@ class ClientHandshakeTest { var userinfo = "first" val handshake = ClientHandshake(network) { "$it $userinfo" } val peer = remote() - assertTrue(handshake.begin(peer, false)) + assertTrue(handshake.begin(peer)) assertEquals(listOf(true), network.binds) handshake.tick(0f) assertEquals(ConnectionlessCommand.getchallenge, network.sent.single().command) @@ -67,8 +63,8 @@ class ClientHandshakeTest { val network = Network() val handshake = ClientHandshake(network) { it.toString() } assertFalse(handshake.acceptConnection(remote())) - assertTrue(handshake.begin(remote(), false)) - val wrongPeers = listOf(remote(27911), remote().apply { ip[3] = 2 }, remote().apply { type = NetAddrType.NA_LOOPBACK }) + assertTrue(handshake.begin(remote())) + val wrongPeers = listOf(remote(27911), NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 2)), 27910), network.localPeer) for (peer in wrongPeers) { assertFalse(handshake.receiveChallenge(peer, "42")) assertFalse(handshake.acceptConnection(peer)) @@ -86,32 +82,42 @@ class ClientHandshakeTest { fun `local attachment bypasses challenge and reset retains an isolated target for reconnect`() { val network = Network() val handshake = ClientHandshake(network) { it.toString() } - val address = netadr_t() - assertTrue(handshake.begin(address, true)) - address.ip[0] = 99 - handshake.lastTarget!!.ip[1] = 99 + val address = network.localPeer + assertTrue(handshake.begin(address)) assertTrue(handshake.isLocal) assertEquals(listOf(false), network.binds) handshake.tick(0f) assertEquals(ConnectionlessCommand.connect, network.sent.single().command) assertEquals("0", network.sent.single().payload) - assertEquals(netadr_t().ip.toList(), network.sent.single().ip) - assertTrue(handshake.acceptConnection(netadr_t())) + assertEquals(address, network.sent.single().address) + assertTrue(handshake.acceptConnection(address)) handshake.reset() assertFalse(handshake.isLocal) val sent = network.sent.size handshake.tick(10f) assertEquals(sent, network.sent.size) - assertTrue(handshake.begin(handshake.lastTarget!!, true)) + assertTrue(handshake.begin(handshake.lastTarget!!)) handshake.tick(0f) assertEquals(ConnectionlessCommand.connect, network.sent.last().command) } + @Test + fun `local handshake rejects another link or the opposite endpoint`() { + val network = Network() + val handshake = ClientHandshake(network) { it.toString() } + assertTrue(handshake.begin(network.localPeer)) + assertFalse(handshake.acceptConnection(jake2.qcommon.network.transport.LoopbackLink().second.localAddress)) + val wrongEndpoint = network.localPeer.copy(side = jake2.qcommon.network.transport.LocalSide.FIRST) + assertFalse(handshake.acceptConnection(wrongEndpoint)) + assertFalse(handshake.receiveChallenge(wrongEndpoint, "42")) + assertTrue(handshake.acceptConnection(network.localPeer)) + } + @Test fun `server restart retries immediately and map changing pauses pending replies`() { val network = Network() val handshake = ClientHandshake(network) { it.toString() } - handshake.begin(remote(), false) + handshake.begin(remote()) assertTrue(handshake.acceptConnection(remote())) handshake.restart() handshake.tick(0f) @@ -127,12 +133,12 @@ class ClientHandshakeTest { fun `failed bind preserves active target and retry state`() { val network = Network() val handshake = ClientHandshake(network) { it.toString() } - handshake.begin(remote(), false) + handshake.begin(remote()) network.bindSucceeds = false - assertFalse(handshake.begin(remote(12345), false)) - assertEquals(27910, handshake.lastTarget!!.port) + assertFalse(handshake.begin(remote(12345))) + assertEquals(27910, (handshake.lastTarget as NetworkAddress.Udp).port) handshake.tick(0f) - assertEquals(27910, network.sent.single().port) + assertEquals(27910, (network.sent.single().address as NetworkAddress.Udp).port) assertTrue(handshake.acceptConnection(remote())) } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt index 5363fcc8..8c701f2b 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt @@ -2,7 +2,6 @@ package org.demoth.cake.network import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.transport.* @@ -29,12 +28,12 @@ class ClientNetworkTest { assertSame(binding, transport.udpEndpoint) client.stop() assertFalse(binding.isOpen) - client.sendConnectionless(netadr_t(), ConnectionlessCommand.getchallenge, "\n") + client.sendConnectionless(client.localPeer, ConnectionlessCommand.getchallenge, "\n") val received = link.second.receive() as ReceiveResult.Received assertEquals("getchallenge\n", received.datagram.payload.toByteArray() .drop(4).toByteArray().toString(Charsets.ISO_8859_1)) val channel = client.newChannel() - channel.setup(netadr_t(), 42) + channel.setup(channel.remoteAddress, 42) assertTrue(channel.transmit(null)) assertTrue(link.second.receive() is ReceiveResult.Received) } diff --git a/qcommon/src/main/java/jake2/qcommon/network/NetAddrType.java b/qcommon/src/main/java/jake2/qcommon/network/NetAddrType.java deleted file mode 100644 index 998bdbf5..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/NetAddrType.java +++ /dev/null @@ -1,9 +0,0 @@ -package jake2.qcommon.network; - -public enum NetAddrType { - NA_LOOPBACK, - NA_BROADCAST, - NA_IP, - NA_IPX, - NA_BROADCAST_IPX, -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index 566edb90..d15486a3 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -1,13 +1,11 @@ package jake2.qcommon.network.messages; -import jake2.qcommon.network.NetAddrType; import jake2.qcommon.network.protocol.PacketEnvelope; import jake2.qcommon.network.transport.NetworkAddress; import jake2.qcommon.network.messages.client.ClientMessage; import jake2.qcommon.network.messages.server.ServerMessage; import jake2.qcommon.network.protocol.MessageDecodeResult; import jake2.qcommon.network.protocol.MessageBodyCodec; -import jake2.qcommon.network.netadr_t; import jake2.qcommon.sizebuf_t; @@ -29,22 +27,17 @@ public class NetworkPacket { public String connectionlessParameters; // client only - public netadr_t from = new netadr_t(); + public final NetworkAddress from; public sizebuf_t buffer = new sizebuf_t(); - /** Adapt a validated envelope to the mutable legacy channel/message reader. */ + /** Adapt a validated envelope to the engine channel/message reader while retaining the exact immutable source. */ public static NetworkPacket fromEnvelope(NetworkAddress source, PacketEnvelope envelope) { - NetworkPacket packet = new NetworkPacket(); + NetworkPacket packet = new NetworkPacket(source); packet.envelope = envelope; packet.buffer.data = envelope.getBytes().toByteArray(); packet.buffer.cursize = packet.buffer.data.length; packet.buffer.maxsize = packet.buffer.data.length; - if (source instanceof NetworkAddress.Udp udp) { - packet.from.type = NetAddrType.NA_IP; - packet.from.ip = udp.getIp().toByteArray(); - packet.from.port = udp.getPort(); - } if (envelope instanceof PacketEnvelope.Sequenced sequenced) { packet.sequence = sequenced.getSequence(); packet.sequenceAck = sequenced.getSequenceAck(); @@ -59,7 +52,8 @@ public static NetworkPacket fromEnvelope(NetworkAddress source, PacketEnvelope e return packet; } - private NetworkPacket() { + private NetworkPacket(NetworkAddress source) { + this.from = java.util.Objects.requireNonNull(source); } public boolean isConnectionless() { diff --git a/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java b/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java deleted file mode 100644 index 150678a1..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 27.11.2003 by RST. -// $Id: netadr_t.java,v 1.6 2005-10-26 12:37:58 cawe Exp $ -package jake2.qcommon.network; - -import jake2.qcommon.Com; -import jake2.qcommon.util.Lib; - -import java.net.InetAddress; -import java.net.UnknownHostException; - -import java.util.Arrays; -import jake2.qcommon.network.transport.NetworkAddress; -import jake2.qcommon.network.transport.IpAddress; - -/** Legacy address bridge; transport endpoints themselves use immutable addresses. */ -public class netadr_t { - - public NetAddrType type; - - public int port; - - public byte[] ip; - - /** - * @param s String address, like 10.132.12.54:4312 - * @return a fresh address value, or null if the string s is not a valid address - */ - public static netadr_t fromString(String s, int defaultPort) { - if (s.equalsIgnoreCase("localhost") || s.equalsIgnoreCase("loopback")) { - return new netadr_t(); - } else { - try { - netadr_t result = new netadr_t(); - String[] address = s.split(":"); - InetAddress ia = InetAddress.getByName(address[0]); - result.ip = ia.getAddress(); - result.type = NetAddrType.NA_IP; - if (address.length == 2) - result.port = Lib.atoi(address[1]); - if (result.port == 0) - result.port = defaultPort; - return result; - } catch (Exception e) { - Com.Println(e.getMessage()); - return null; - } - } - } - - public netadr_t() { - this.type = NetAddrType.NA_LOOPBACK; - this.port = 0; // any - // localhost / 127.0.0.1 - this.ip = new byte[] {127, 0, 0, 1}; - } - - /** - * Returns true for in-process loopback addresses. A UDP address on 127.0.0.1 is still remote. - */ - public boolean IsLocalAddress() { - return type == NetAddrType.NA_LOOPBACK; - } - - public boolean compareIp(netadr_t other) { - return Arrays.equals(ip, other.ip) && port == other.port; - } - /** - * Compares ip address without the port. - * Returns true for matching LOOPBACK types; the owned transport enforces local-link identity. - */ - public boolean CompareBaseAdr(netadr_t other) { - if (this.type != other.type) - return false; - - if (this.type == NetAddrType.NA_LOOPBACK) - return true; - - if (this.type == NetAddrType.NA_IP) { - return Arrays.equals(ip, other.ip); - } - return false; - } - - - public InetAddress getInetAddress() throws UnknownHostException { - switch (type) { - case NA_BROADCAST: - return InetAddress.getByName("255.255.255.255"); - case NA_LOOPBACK: - // localhost / 127.0.0.1 - return InetAddress.getByName(null); - case NA_IP: - return InetAddress.getByAddress(ip); - default: - return null; - } - } - - /** Resolve this legacy destination using the owner's explicit local peer, never a global role. */ - public NetworkAddress toNetworkAddress(NetworkAddress.Local localPeer) throws UnknownHostException { - if (type == NetAddrType.NA_LOOPBACK) return localPeer; - if (type != NetAddrType.NA_IP && type != NetAddrType.NA_BROADCAST) - throw new IllegalArgumentException("Unsupported address type " + type); - return new NetworkAddress.Udp(IpAddress.fromInetAddress(getInetAddress()), port); - } - - public void set(netadr_t from) { - type = from.type; - port = from.port; - ip = from.ip.clone(); - } - - public String toString() { - return new StringBuilder() - .append(ip[0] & 0xFF).append('.') - .append(ip[1] & 0xFF).append('.') - .append(ip[2] & 0xFF).append('.') - .append(ip[3] & 0xFF).append(':') - .append(port).toString(); - - } -} \ No newline at end of file diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt index 9eb9729a..8a6fc5e0 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt @@ -46,7 +46,7 @@ class ConnectionChannel @JvmOverloads constructor( private val clockMillis: LongSupplier, ) { private var channel = ReliableChannel(role, 0) - private var destination = netadr_t() + private var destination: NetworkAddress = localPeer private val pending = ArrayList() private var idleSinceMillis = clockMillis.asLong @@ -68,8 +68,8 @@ class ConnectionChannel @JvmOverloads constructor( val canSendReliable: Boolean get() = channel.canSendReliable val needsReliable: Boolean get() = channel.needsReliable || (channel.canSendReliable && pending.isNotEmpty()) - /** Detached legacy address view until active address boundaries migrate to immutable values. */ - val remoteAddress: netadr_t get() = netadr_t().also { it.set(destination) } + /** Immutable peer address, including private local-link identity. */ + val remoteAddress: NetworkAddress get() = destination /** Engine staging remains here until session controllers own message encoding. No mutable list escapes. */ fun queueReliable(message: NetworkMessage) { pending.add(message) } @@ -78,9 +78,9 @@ class ConnectionChannel @JvmOverloads constructor( val queuedReliableBytes: Long get() = pending.sumOf { it.size.toLong() } val queuedReliable: List get() = Collections.unmodifiableList(ArrayList(pending)) - fun setup(address: netadr_t, qport: Int) { + fun setup(address: NetworkAddress, qport: Int) { clear() - destination.set(address) + destination = address this.qport = qport channel = ReliableChannel(role, qport and 0xffff) } @@ -88,7 +88,8 @@ class ConnectionChannel @JvmOverloads constructor( /** Commit NAT source-port changes only after the router accepts the sequenced packet. */ fun updateRemotePort(port: Int) { require(port in 0..65535) - destination.port = port + val remote = destination as? NetworkAddress.Udp ?: error("Local peers have no UDP port") + destination = remote.copy(port = port) } /** Keepalive timing shares the injected monotonic clock with acknowledgements and RTT tracking. */ @@ -153,7 +154,7 @@ class ConnectionChannel @JvmOverloads constructor( } private fun send(bytes: PacketBytes): SendResult = try { - sendPacket(destination.toNetworkAddress(localPeer), bytes) + sendPacket(destination, bytes) } catch (error: IOException) { SendResult.Failed(error) } @@ -171,7 +172,7 @@ class ConnectionChannel @JvmOverloads constructor( fun clear() { fatalError = false qport = 0 - destination = netadr_t() + destination = localPeer pending.clear() channel = ReliableChannel(role, 0) idleSinceMillis = clockMillis.asLong diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index c71ca941..23ffd194 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -4,7 +4,6 @@ import jake2.qcommon.Com import jake2.qcommon.Defines import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket @@ -32,6 +31,10 @@ class PacketNetwork( private val label = if (role == NetworkRole.CLIENT) "Client" else "Server" private val direction = if (role == NetworkRole.CLIENT) PacketDirection.FROM_SERVER else PacketDirection.FROM_CLIENT + val localPeer: NetworkAddress.Local get() = transport.localPeer + + fun resolveAddress(text: String, defaultPort: Int): NetworkAddress? = AddressResolver.resolve(text, defaultPort, localPeer) + fun newChannel(): ConnectionChannel { check(!closed) { "$label network is closed" } return ConnectionChannel( @@ -105,12 +108,11 @@ class PacketNetwork( } /** Out-of-band datagram: a -1 sequence marker followed by Latin-1 command/payload bytes. */ - fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) { + fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) { val text = (command.name + payload).toByteArray(Charsets.ISO_8859_1) require(text.size <= Defines.MAX_MSGLEN - 4) val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() - val destination = to.toNetworkAddress(transport.localPeer) - val result = transport.send(destination, PacketBytes.copyOf(bytes)) + val result = transport.send(to, PacketBytes.copyOf(bytes)) if (result is SendResult.Failed) Com.Println("$label send error: ${result.cause}") } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/AddressResolver.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/AddressResolver.kt new file mode 100644 index 00000000..7d9dfb83 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/AddressResolver.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 1997-2001 Id Software, Inc. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 59 Temple + * Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ + +// Created on 27.11.2003 by RST. +// $Id: netadr_t.java,v 1.6 2005-10-26 12:37:58 cawe Exp $ +package jake2.qcommon.network.transport + +import java.net.InetAddress +import java.net.UnknownHostException + +/** Parsed connection intent; local aliases are resolved against the owner's explicit local peer. */ +sealed interface AddressTarget { + data object Local : AddressTarget + data class Host(val name: String, val port: Int) : AddressTarget { + init { require(name.isNotEmpty() && port in 1..65535) } + } +} + +/** Address parsing creates no sockets. DNS resolution is explicit and injectable. */ +object AddressResolver { + /** + * Parse a host, IPv4 host:port (e.g. 10.132.12.54:4312), or bracketed IPv6 with an optional port. + * Returns null for invalid text; localhost/loopback without a port designate an in-process peer. + */ + @JvmStatic fun parse(text: String, defaultPort: Int): AddressTarget? { + require(defaultPort in 1..65535) + if (text.equals("localhost", true) || text.equals("loopback", true)) return AddressTarget.Local + if (text.isEmpty() || text.any { it.isWhitespace() || it.isISOControl() }) return null + val host: String + val portText: String? + if (text.startsWith("[")) { + val end = text.indexOf(']') + if (end < 2) return null + host = text.substring(1, end) + if (':' !in host || '[' in host) return null + val suffix = text.substring(end + 1) + if (suffix.isNotEmpty() && !suffix.startsWith(":")) return null + portText = if (suffix.isEmpty()) null else suffix.substring(1) + } else { + if ('[' in text || ']' in text) return null + val colon = text.indexOf(':') + if (colon >= 0 && colon == text.lastIndexOf(':')) { + host = text.substring(0, colon) + portText = text.substring(colon + 1) + } else { + host = text + portText = null + } + } + if (host.isEmpty()) return null + val port = if (portText == null) defaultPort else portText.toIntOrNull()?.takeIf { it in 0..65535 } ?: return null + // Preserve the legacy explicit-zero port fallback. + return AddressTarget.Host(host, if (port == 0) defaultPort else port) + } + + /** Resolve a parsed host without losing IPv6 scope identity; local connections never consult DNS. */ + fun resolve(target: AddressTarget, localPeer: NetworkAddress.Local, + lookup: (String) -> InetAddress = InetAddress::getByName): NetworkAddress? = try { + when (target) { + AddressTarget.Local -> localPeer + is AddressTarget.Host -> NetworkAddress.Udp(IpAddress.fromInetAddress(lookup(target.name)), target.port) + } + } catch (_: UnknownHostException) { null } + + @JvmStatic fun resolve(text: String, defaultPort: Int, localPeer: NetworkAddress.Local): NetworkAddress? = + parse(text, defaultPort)?.let { resolve(it, localPeer) } + + /** UI validation uses the same parser and DNS policy, without needing a transport or local-link ID. */ + @JvmStatic fun isValid(text: String, defaultPort: Int): Boolean = when (val target = parse(text, defaultPort)) { + null -> false + AddressTarget.Local -> true + is AddressTarget.Host -> try { InetAddress.getByName(target.name); true } catch (_: UnknownHostException) { false } + } +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt index 88f3d679..0b1136fe 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt @@ -34,12 +34,19 @@ class IpAddress private constructor(private val bytes: ByteArray, private val sc } } +/** + * Immutable transport address. Only Local represents an in-process connection: a UDP address on + * 127.0.0.1 is still remote. Base-address routing ignores UDP ports but compares complete local-link + * and endpoint identity; an unrelated local link must never alias this peer. + */ sealed interface NetworkAddress { data class Udp(val ip: IpAddress, val port: Int) : NetworkAddress { init { require(port in 0..65535) { "UDP port out of range: $port" } } + override fun toString(): String = if (':' in ip.toString()) "[$ip]:$port" else "$ip:$port" + fun toSocketAddress(): InetSocketAddress = InetSocketAddress(ip.toInetAddress(), port) companion object { diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java deleted file mode 100644 index 7139f5b8..00000000 --- a/qcommon/src/test/java/jake2/qcommon/network/NetAddressBridgeTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package jake2.qcommon.network; - -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -class NetAddressBridgeTest { - @Test - void localParsingReturnsIndependentValuesAndUdpLoopbackRemainsRemote() { - var first = netadr_t.fromString("localhost", 27910); - var second = netadr_t.fromString("loopback", 27910); - assertNotSame(first, second); - assertNotSame(first.ip, second.ip); - first.ip[0] = 99; - first.port = 1234; - first.type = NetAddrType.NA_IP; - var third = netadr_t.fromString("LOCALHOST", 27910); - assertArrayEquals(new byte[]{127, 0, 0, 1}, third.ip); - assertEquals(0, third.port); - assertTrue(third.IsLocalAddress()); - assertFalse(netadr_t.fromString("127.0.0.1:27910", 27910).IsLocalAddress()); - } - - @Test - void copyingOwnsAllAddressBytesAndComparisonsUseTheFullAddress() { - var source = new netadr_t(); - source.type = NetAddrType.NA_IP; - source.ip = new byte[]{32, 1, 13, (byte)184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; - source.port = 27910; - var copy = new netadr_t(); - copy.set(source); - assertNotSame(source.ip, copy.ip); - assertArrayEquals(source.ip, copy.ip); - assertTrue(source.compareIp(copy)); - copy.port++; - assertFalse(source.compareIp(copy)); - assertTrue(source.CompareBaseAdr(copy)); - copy.ip[15] = 2; - assertFalse(source.CompareBaseAdr(copy)); - assertEquals(1, source.ip[15]); - } -} diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index a8222114..c26c86b5 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -53,7 +53,7 @@ void receivesWholeDatagramAtSizeBoundary(int length, boolean fromClient) throws assertEquals(7, packet.sequence); assertEquals(3, packet.sequenceAck); assertEquals(fromClient ? 42 : 0, packet.qport); - assertEquals(NetAddrType.NA_IP, packet.from.type); + assertInstanceOf(NetworkAddress.Udp.class, packet.from); } @Test @@ -91,7 +91,7 @@ void connectionlessStringEndsAtDatagramBoundaryWithoutTerminator() throws Except void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { assertSame(SendResult.Sent.INSTANCE, pair.clientTransport.send( pair.clientTransport.getLocalPeer(), PacketBytes.copyOf(sequencedPacket(8, false)))); - pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + pair.client.sendConnectionless(pair.client.getLocalPeer(), ConnectionlessCommand.getchallenge, "\n"); assertNull(pair.server.receive()); NetworkPacket next = pair.server.receive(); @@ -101,7 +101,7 @@ void truncatedLoopbackHeaderIsDroppedWithoutConsumingFollowingPacket() { @Test void serverPrintReplySeparatesCommandFromMultilineParameters() { - pair.server.sendConnectionless(new netadr_t(), ConnectionlessCommand.print, + pair.server.sendConnectionless(pair.server.getLocalPeer(), ConnectionlessCommand.print, "\nFirst line\nSecond line\n"); NetworkPacket packet = pair.client.receive(); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index 2ce908e3..aec24fa4 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -90,10 +90,10 @@ void occupiedClientPortFallsBackOnExplicitBindAddress() throws Exception { @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { - pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + pair.client.sendConnectionless(pair.client.getLocalPeer(), ConnectionlessCommand.getchallenge, "\n"); assertNotNull(pair.server.receive()); - pair.server.sendConnectionless(new netadr_t(), ConnectionlessCommand.client_connect, ""); - pair.client.sendConnectionless(new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); + pair.server.sendConnectionless(pair.server.getLocalPeer(), ConnectionlessCommand.client_connect, ""); + pair.client.sendConnectionless(pair.client.getLocalPeer(), ConnectionlessCommand.getchallenge, "\n"); pair.clearLocalTraffic(); assertNull(pair.client.receive()); assertNull(pair.server.receive()); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index 4298494f..95c6e036 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -50,7 +50,7 @@ void cleanup() throws Exception { void outgoingClientPacketUsesItsSetupQport() { Netchan.qport.forceSet(77); var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), client::send, () -> (long) Globals.curtime); - channel.setup(new netadr_t(), 1234); + channel.setup(channel.getRemoteAddress(), 1234); assertTrue(channel.transmit(null)); assertEquals(1234, serverPackets.receive().qport); } @@ -61,7 +61,7 @@ void unavailableSenderDoesNotAdvanceOrLoseStagedMessages() { var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), (to, bytes) -> available[0] ? client.send(to, bytes) : new SendResult.Failed(new java.io.IOException("unavailable")), () -> (long) Globals.curtime); - channel.setup(new netadr_t(), 7); + channel.setup(channel.getRemoteAddress(), 7); channel.queueReliable(new StringCmdMessage("new")); Globals.curtime = 100; assertFalse(channel.transmit(null)); @@ -85,7 +85,7 @@ void unexpectedSendFailureReleasesPreparedTransaction() { if (fail[0]) throw new IllegalStateException("injected send failure"); return client.send(to, bytes); }, () -> (long) Globals.curtime); - channel.setup(new netadr_t(), 7); + channel.setup(channel.getRemoteAddress(), 7); channel.queueReliable(new StringCmdMessage("new")); assertThrows(IllegalStateException.class, () -> channel.transmit(null)); assertEquals(1, channel.getOutgoingSequence()); @@ -97,7 +97,7 @@ void unexpectedSendFailureReleasesPreparedTransaction() { @Test void recordsSentPacketSizeAfterPacketIsBuilt() { ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); - netchan.setup(new netadr_t(), 0); + netchan.setup(netchan.getRemoteAddress(), 0); Globals.curtime = 100; netchan.transmit(null); @@ -108,7 +108,7 @@ void recordsSentPacketSizeAfterPacketIsBuilt() { @Test void smoothsPingFromAcknowledgedPacketSequence() { ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); - netchan.setup(new netadr_t(), 0); + netchan.setup(netchan.getRemoteAddress(), 0); Globals.curtime = 100; netchan.transmit(null); // sequence 1 diff --git a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt index c24f67f6..96a8c0ef 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -3,12 +3,9 @@ package jake2.qcommon import jake2.qcommon.network.runtime.* import jake2.qcommon.network.transport.LocalSide import jake2.qcommon.network.messages.ConnectionlessCommand -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan @@ -44,9 +41,8 @@ class OutputFailureTest { assertArrayEquals(saved, buffer.data) } - @ParameterizedTest - @EnumSource(NetAddrType::class, names = ["NA_IPX", "NA_BROADCAST_IPX"]) - fun `unsupported destination propagates without closing the socket`(type: NetAddrType) { + @Test + fun `oversized connectionless output propagates without closing the socket`() { val cvars = Cvar.getInstance() cvars.clear() Netchan.Netchan_Init() @@ -57,9 +53,8 @@ class OutputFailureTest { assertTrue(packets.configure(true)) val endpoint = transport.udpEndpoint!! val failure = assertThrows(IllegalArgumentException::class.java) { - packets.sendConnectionless(netadr_t().apply { this.type = type }, ConnectionlessCommand.print, "\nerror") + packets.sendConnectionless(packets.localPeer, ConnectionlessCommand.print, "x".repeat(Defines.MAX_MSGLEN)) } - assertTrue(failure.message!!.contains(type.name)) assertSame(endpoint, transport.udpEndpoint) assertTrue(endpoint.isOpen) } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt index 2f052467..8403bc3c 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -65,7 +65,7 @@ class PacketCodecTest { assertEquals(direction.sequencedHeaderSize, packet.buffer.readcount) assertEquals(0x23, packet.buffer.readByte()) assertEquals(0x45, packet.buffer.readByte()) - assertEquals(27910, packet.from.port) + assertEquals(27910, (packet.from as NetworkAddress.Udp).port) packet.buffer.data.fill(0) packet.sequence = 0 assertArrayEquals(wire, envelope.bytes.toByteArray()) diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/AddressResolverTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/AddressResolverTest.kt new file mode 100644 index 00000000..1b995df4 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/AddressResolverTest.kt @@ -0,0 +1,80 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.InetAddress +import java.net.Inet6Address +import java.net.UnknownHostException + +class AddressResolverTest { + @Test + fun `local aliases resolve only to the supplied owned peer without DNS`() { + val first = LoopbackLink() + val second = LoopbackLink() + for (alias in listOf("localhost", "LOCALHOST", "loopback", "LOOPBACK")) { + val target = requireNotNull(AddressResolver.parse(alias, 27910)) + assertSame(AddressTarget.Local, target) + assertEquals(first.second.localAddress, AddressResolver.resolve(target, first.second.localAddress) { error("Local aliases must not resolve DNS") }) + assertNotEquals(AddressResolver.resolve(target, first.second.localAddress), AddressResolver.resolve(target, second.second.localAddress)) + } + assertNotEquals(first.first.localAddress, first.second.localAddress) + assertTrue(AddressResolver.isValid("localhost", 27910)) + } + + @Test + fun `UDP parsing retains default explicit and zero fallback ports and bracketed IPv6`() { + for ((text, expected) in mapOf( + "example.test" to AddressTarget.Host("example.test", 27910), + "10.132.12.54:4312" to AddressTarget.Host("10.132.12.54", 4312), + "localhost:27911" to AddressTarget.Host("localhost", 27911), + "127.0.0.1:0" to AddressTarget.Host("127.0.0.1", 27910), + "[2001:db8::1]:65535" to AddressTarget.Host("2001:db8::1", 65535), + "[::1]" to AddressTarget.Host("::1", 27910), + "::1" to AddressTarget.Host("::1", 27910), + )) assertEquals(expected, AddressResolver.parse(text, 27910)) + val loopback = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) + val peer = LoopbackLink().second.localAddress + val udp = AddressResolver.resolve(AddressTarget.Host("localhost", 27910), peer) { loopback } + assertInstanceOf(NetworkAddress.Udp::class.java, udp) + assertNotEquals(peer, udp) + assertEquals("127.0.0.1:27910", udp.toString()) + } + + @Test + fun `invalid syntax fails before lookup and unknown hosts fail resolution without creating resources`() { + for (text in listOf("", " ", ":27910", "host:", "host:nope", "host:-1", "host:65536", "host:2147483648", "host:12x", "bad host", "host\n", "[::1", "[]", "[::1]junk", "[::1]:", "[::1]:-1", "[127.0.0.1]")) + assertNull(AddressResolver.parse(text, 27910), text) + val peer = LoopbackLink().second.localAddress + val target = requireNotNull(AddressResolver.parse("missing.test", 27910)) + assertNull(AddressResolver.resolve(target, peer) { throw UnknownHostException(it) }) + } + + @Test + fun `scoped IPv6 values retain scope and exported bytes cannot change resolved addresses`() { + val raw = InetAddress.getByName("fe80::1").address + val scoped = Inet6Address.getByAddress(null, raw, 7) + val peer = LoopbackLink().second.localAddress + val address = AddressResolver.resolve(AddressTarget.Host("scoped.test", 27910), peer) { scoped } as NetworkAddress.Udp + raw.fill(0) + address.ip.toByteArray().fill(0) + assertEquals(7, (address.ip.toInetAddress() as Inet6Address).scopeId) + assertEquals(address, address.copy()) + assertTrue(address.toString().startsWith("[")) + assertTrue(address.toString().endsWith("]:27910")) + } + + @Test + fun `decoded local packet retains link and endpoint identity`() { + val first = LoopbackLink() + val second = LoopbackLink() + val wire = PacketCodec.encodeSequenced(1, 0, 42, PacketBytes.copyOf(byteArrayOf())) + val envelope = (PacketCodec.decode(wire, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + val packet = NetworkPacket.fromEnvelope(first.first.localAddress, envelope) + assertSame(first.first.localAddress, packet.from) + assertNotEquals(first.second.localAddress, packet.from) + assertNotEquals(second.first.localAddress, packet.from) + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt index c694ad0f..f68de611 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt @@ -2,8 +2,6 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Defines import jake2.qcommon.network.ConnectionChannel -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.protocol.* @@ -19,7 +17,7 @@ class ConnectionChannelTest { var now = Int.MAX_VALUE.toLong() + 100_000 var result: SendResult = SendResult.Sent val channel = ConnectionChannel(ChannelRole.CLIENT, link.second.localAddress, { _, _ -> result }) { now } - channel.setup(netadr_t(), 42) + channel.setup(channel.remoteAddress, 42) assertFalse(channel.hasBeenIdleFor(1000)) now += 1000 assertFalse(channel.hasBeenIdleFor(1000)) @@ -48,11 +46,11 @@ class ConnectionChannelTest { val channel = ConnectionChannel(ChannelRole.SERVER, link.first.localAddress, { to, _ -> destinations += to; SendResult.Sent }) { 0L } - val address = netadr_t().apply { type = NetAddrType.NA_IP; port = 27910; ip = byteArrayOf(10, 1, 2, 3) } + val address = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(10, 1, 2, 3)), 27910) channel.setup(address, 1234) - address.ip.fill(99); address.port = 1 + address.ip.toByteArray().fill(99) val view = channel.remoteAddress - view.ip.fill(88); view.port = 2; view.type = NetAddrType.NA_LOOPBACK + (view as NetworkAddress.Udp).ip.toByteArray().fill(88) channel.transmit(null) channel.updateRemotePort(27911) channel.transmit(null) @@ -68,7 +66,7 @@ class ConnectionChannelTest { val channel = ConnectionChannel(ChannelRole.CLIENT, link.second.localAddress, { _, bytes -> wire += bytes; SendResult.Sent }) { 0L } - channel.setup(netadr_t(), 42) + channel.setup(channel.remoteAddress, 42) val command = StringCmdMessage("first") channel.queueReliable(command) val snapshot = channel.queuedReliable @@ -102,7 +100,7 @@ class ConnectionChannelTest { attempts += bytes if (attempts.size == 1) failure else SendResult.Sent }) { 100L } - channel.setup(netadr_t(), 42) + channel.setup(channel.remoteAddress, 42) channel.queueReliable(StringCmdMessage("retry")) assertFalse(channel.transmit(null)) assertEquals(1, channel.outgoingSequence) @@ -128,7 +126,7 @@ class ConnectionChannelTest { channel.clear() assertFalse(channel.fatalError) assertFalse(channel.hasQueuedReliable) - channel.setup(netadr_t(), 42) + channel.setup(channel.remoteAddress, 42) assertTrue(channel.transmit(null)) assertEquals(8, wire.single().size, "A server channel retains its header role across clear/setup") } diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt index 79ea4436..1c5b8538 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -1,7 +1,6 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Defines -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.messages.client.StringCmdMessage @@ -39,8 +38,8 @@ class OwnedChannelTest { val server = ConnectionChannel(ChannelRole.SERVER, serverTransport.localPeer, serverTransport::send) { 100L } init { - client.setup(netadr_t(), 42) - server.setup(netadr_t(), 42) + client.setup(client.remoteAddress, 42) + server.setup(server.remoteAddress, 42) } fun receive(transport: NetworkTransport, direction: PacketDirection): NetworkPacket { @@ -83,7 +82,7 @@ class OwnedChannelTest { fun `clearing a channel retains its borrowed transport and failed send retains staging`() { Session().use { session -> session.client.clear() - session.client.setup(netadr_t(), 99) + session.client.setup(session.client.remoteAddress, 99) assertTrue(session.client.transmit(null)) assertEquals(99, session.receive(session.serverTransport, PacketDirection.FROM_CLIENT).qport) session.clientTransport.close() diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt index 47cab631..2029268d 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -3,7 +3,6 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Defines import jake2.qcommon.exec.Cvar import jake2.qcommon.network.Netchan -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.transport.* import org.junit.jupiter.api.Assertions.* @@ -64,14 +63,14 @@ class PacketNetworkTest { PacketNetwork(clientTransport, NetworkRole.CLIENT).use { client -> PacketNetwork(serverTransport, NetworkRole.SERVER).use { server -> val clientChannel = client.newChannel() - clientChannel.setup(netadr_t(), 1234) + clientChannel.setup(clientChannel.remoteAddress, 1234) assertTrue(clientChannel.transmit(null)) val request = server.receive()!! assertEquals(1234, request.qport) assertEquals(10, request.buffer.readcount) val serverChannel = server.newChannel() - serverChannel.setup(netadr_t(), 1234) + serverChannel.setup(serverChannel.remoteAddress, 1234) assertTrue(serverChannel.accept(request)) assertTrue(serverChannel.transmit(null)) val reply = client.receive()!! @@ -79,13 +78,13 @@ class PacketNetworkTest { assertTrue(clientChannel.accept(reply)) val body = "é".repeat(Defines.MAX_MSGLEN - 4 - "print\n".length) - server.sendConnectionless(netadr_t(), ConnectionlessCommand.print, "\n$body") + server.sendConnectionless(server.localPeer, ConnectionlessCommand.print, "\n$body") val text = client.receive()!! assertEquals(Defines.MAX_MSGLEN, text.buffer.cursize) assertEquals("print", text.connectionlessMessage) assertEquals(body, text.connectionlessParameters) assertThrows(IllegalArgumentException::class.java) { - server.sendConnectionless(netadr_t(), ConnectionlessCommand.print, "\n${body}é") + server.sendConnectionless(server.localPeer, ConnectionlessCommand.print, "\n${body}é") } assertNull(client.receive()) } diff --git a/server/src/main/kotlin/jake2/server/ServerConnections.kt b/server/src/main/kotlin/jake2/server/ServerConnections.kt index 9bc2e0c1..b3051c46 100644 --- a/server/src/main/kotlin/jake2/server/ServerConnections.kt +++ b/server/src/main/kotlin/jake2/server/ServerConnections.kt @@ -1,8 +1,7 @@ package jake2.server import jake2.qcommon.Defines -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.transport.IpAddress import jake2.qcommon.util.Lib @@ -12,13 +11,20 @@ class ServerConnections( private val challengeCapacity: Int = Defines.MAX_CHALLENGES, private val nextToken: () -> Int = { Lib.rand().toInt() }, ) { - private data class PeerKey(val type: NetAddrType, val ip: IpAddress?) { + private sealed interface PeerKey { + data class Remote(val ip: IpAddress) : PeerKey + data class Local(val address: NetworkAddress.Local) : PeerKey companion object { - fun of(address: netadr_t) = PeerKey(address.type, - if (address.type == NetAddrType.NA_LOOPBACK) null else IpAddress.fromBytes(address.ip)) + fun of(address: NetworkAddress): PeerKey = when (address) { + is NetworkAddress.Udp -> Remote(address.ip) + is NetworkAddress.Local -> Local(address) + } } } + private fun samePort(a: NetworkAddress, b: NetworkAddress): Boolean = + (a as? NetworkAddress.Udp)?.port == (b as? NetworkAddress.Udp)?.port + enum class ChallengeValidation { ACCEPTED, BAD, MISSING } sealed interface Admission { data class Slot(val index: Int, val reconnect: Boolean) : Admission @@ -46,29 +52,29 @@ class ServerConnections( * entry. Map insertion order tracks issuance even within one clock tick and across global * clock resets/wrap. Session stop calls [reset] to clear the cache; this order is never sent. */ - fun issueChallenge(from: netadr_t): Int { + fun issueChallenge(from: NetworkAddress): Int { val key = PeerKey.of(from) challenges[key]?.let { return it } if (challenges.size == challengeCapacity) challenges.remove(challenges.keys.first()) return (nextToken() and 0x7fff).also { challenges[key] = it } } - fun validateChallenge(from: netadr_t, token: Int): ChallengeValidation { - if (from.type == NetAddrType.NA_LOOPBACK) return ChallengeValidation.ACCEPTED + fun validateChallenge(from: NetworkAddress, token: Int): ChallengeValidation { + if (from is NetworkAddress.Local) return ChallengeValidation.ACCEPTED val expected = challenges[PeerKey.of(from)] ?: return ChallengeValidation.MISSING return if (expected == token) ChallengeValidation.ACCEPTED else ChallengeValidation.BAD } fun selectSlot( - clients: List, from: netadr_t, qport: Int, + clients: List, from: NetworkAddress, qport: Int, nowMillis: Long, reconnectDelayMillis: Float, ): Admission { val key = PeerKey.of(from) for ((index, client) in clients.withIndex()) { if (client.state == ClientStates.CS_FREE) continue if (PeerKey.of(client.netchan.remoteAddress) != key) continue - if (client.netchan.qport != qport && client.netchan.remoteAddress.port != from.port) continue - if (from.type != NetAddrType.NA_LOOPBACK && nowMillis - client.lastconnect < reconnectDelayMillis) + if (client.netchan.qport != qport && !samePort(client.netchan.remoteAddress, from)) continue + if (from !is NetworkAddress.Local && nowMillis - client.lastconnect < reconnectDelayMillis) return Admission.TooSoon return Admission.Slot(index, reconnect = true) } @@ -85,8 +91,8 @@ class ServerConnections( PeerKey.of(it.netchan.remoteAddress) == key } ?: return null if (!client.netchan.accept(packet)) return null - val changed = client.netchan.remoteAddress.port != packet.from.port - if (changed) client.netchan.updateRemotePort(packet.from.port) + val changed = !samePort(client.netchan.remoteAddress, packet.from) + if (changed) client.netchan.updateRemotePort((packet.from as NetworkAddress.Udp).port) return Routed(client, changed) } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 5d92a168..b09a5a6e 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -26,12 +26,11 @@ import jake2.qcommon.exec.* import jake2.qcommon.exec.CvarTypes.ChoiceType import jake2.qcommon.network.protocol.MessageDecodeResult import jake2.qcommon.network.Netchan -import jake2.qcommon.network.NetAddrType import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.client.* import jake2.qcommon.network.messages.server.* -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.util.Lib import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot @@ -972,7 +971,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } /** Validate a direct connect request, including its remote-address challenge, before admission. */ - private fun connectClient(args: List, adr: netadr_t) { + private fun connectClient(args: List, adr: NetworkAddress) { Com.DPrintf("SVC_DirectConnect ()\n") val version = if (args.size >= 2) Lib.atoi(args[1]) else 0 @@ -1020,7 +1019,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) /** * Initializes player structures after successfull connection. */ - private fun initializeClientConnection(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { + private fun initializeClientConnection(i: Int, challenge: Int, userinfo: String?, adr: NetworkAddress, qport: Int) { // build a new connection // accept the new client // this is the only place a ClientNetworkInfo is ever initialized @@ -1073,7 +1072,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) * it from a game channel. Clients that are in the game can still send * connectionless packets. It is used also by rcon commands. */ - fun handleConnectionlessPacket(from: netadr_t, messageLine: String?) { + fun handleConnectionlessPacket(from: NetworkAddress, messageLine: String?) { val args = Cmd.TokenizeString(messageLine, false) if (args.isEmpty()) { @@ -1701,7 +1700,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) fun shouldRateLimit(c: ClientNetworkInfo): Boolean { // never drop over the loopback - if (c.netchan.remoteAddress.type == NetAddrType.NA_LOOPBACK) return false + if (c.netchan.remoteAddress is NetworkAddress.Local) return false var total = 0 @@ -1920,7 +1919,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) * A client issued an rcon command. Shift down the remaining args Redirect * all printfs from the server to the client. */ - private fun executeRemoteCommand(args: List, from: netadr_t) { + private fun executeRemoteCommand(args: List, from: NetworkAddress) { val rconIsValid: Boolean = isRconAuthorized(args) if (rconIsValid) { diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index 7e4e7ae2..9789a760 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -1,6 +1,8 @@ package jake2.server import jake2.qcommon.network.* +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.transport.AddressResolver import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.runtime.NetworkTransport @@ -10,10 +12,12 @@ import jake2.qcommon.network.transport.LocalSide /** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ServerNetwork : AutoCloseable { + val localPeer: NetworkAddress.Local + fun resolveAddress(text: String, defaultPort: Int): NetworkAddress? = AddressResolver.resolve(text, defaultPort, localPeer) fun configure(multiplayer: Boolean): Boolean fun newChannel(): ConnectionChannel fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int - fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) + fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) fun stop() fun sleep(millis: Int) override fun close() = stop() @@ -26,10 +30,11 @@ class TransportServerNetwork(private val transport: NetworkTransport) : ServerNe NetworkTransport.standalone(LocalSide.SECOND)) } private val packets = PacketNetwork(transport, NetworkRole.SERVER) + override val localPeer: NetworkAddress.Local get() = packets.localPeer override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) - override fun sendConnectionless(to: netadr_t, command: ConnectionlessCommand, payload: String) = + override fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) = packets.sendConnectionless(to, command, payload) override fun stop() = packets.stop() override fun close() = packets.close() diff --git a/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt index f26ac059..606a82ee 100644 --- a/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt @@ -1,8 +1,6 @@ package jake2.server import jake2.qcommon.Defines -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.protocol.* @@ -12,11 +10,7 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class ServerConnectionsTest { - private fun address(host: Int = 1, port: Int = 10000) = netadr_t().apply { - type = NetAddrType.NA_IP - ip = byteArrayOf(192.toByte(), 0, 2, host.toByte()) - this.port = port - } + private fun address(host: Int = 1, port: Int = 10000) = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(192.toByte(), 0, 2, host.toByte())), port) @Test fun `bounded challenges own addresses and reuse without refreshing issuance order`() { @@ -24,7 +18,7 @@ class ServerConnectionsTest { val connections = ServerConnections(2) { token++ } val first = address() assertEquals(32767, connections.issueChallenge(first)) - first.ip[3] = 99 + first.ip.toByteArray()[3] = 99 assertEquals(0, connections.issueChallenge(address(2))) assertEquals(32767, connections.issueChallenge(address(port = 54321))) assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(address(), 32767)) @@ -34,9 +28,9 @@ class ServerConnectionsTest { assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(address(2), 0)) connections.reset() assertEquals(ServerConnections.ChallengeValidation.MISSING, connections.validateChallenge(address(2), 0)) - assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(netadr_t(), 123)) + assertEquals(ServerConnections.ChallengeValidation.ACCEPTED, connections.validateChallenge(LoopbackLink().first.localAddress, 123)) assertEquals(ServerConnections.ChallengeValidation.MISSING, - connections.validateChallenge(address().apply { ip = byteArrayOf(127, 0, 0, 1); port = 0 }, 0)) + connections.validateChallenge(NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), 0), 0)) } @Test @@ -62,9 +56,9 @@ class ServerConnectionsTest { connections.selectSlot(clients, address(2), 7, 600, 500f)) assertEquals(ServerConnections.Admission.Full, connections.selectSlot(listOf(occupied), address(2), 7, 600, 500f)) - occupied.netchan.setup(netadr_t(), 7) + occupied.netchan.setup(transport.localPeer, 7) assertEquals(ServerConnections.Admission.Slot(1, true), - connections.selectSlot(clients, netadr_t(), 7, 100, 500f)) + connections.selectSlot(clients, transport.localPeer, 7, 100, 500f)) } } @@ -72,7 +66,36 @@ class ServerConnectionsTest { val source = address(host, port) val bytes = PacketCodec.encodeSequenced(sequence, 0, qport, PacketBytes.copyOf(byteArrayOf())) val envelope = (PacketCodec.decode(bytes, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope - return NetworkPacket.fromEnvelope(NetworkAddress.Udp(IpAddress.fromBytes(source.ip), port), envelope) + return NetworkPacket.fromEnvelope(NetworkAddress.Udp(source.ip, port), envelope) + } + + @Test + fun `local routing and reconnect admission retain link and endpoint identity`() { + val first = LoopbackLink() + val second = LoopbackLink() + fun client(link: LoopbackLink) = ClientNetworkInfo(ConnectionChannel( + ChannelRole.SERVER, link.first.localAddress, { _, _ -> SendResult.Sent }) { 0L }).apply { + state = ClientStates.CS_CONNECTED + netchan.setup(link.first.localAddress, 7) + } + val a = client(first) + val b = client(second) + val connections = ServerConnections() + val clients = listOf(a, b) + fun packet(source: NetworkAddress.Local): NetworkPacket { + val bytes = PacketCodec.encodeSequenced(1, 0, 7, PacketBytes.copyOf(byteArrayOf())) + val envelope = (PacketCodec.decode(bytes, PacketDirection.FROM_CLIENT) as PacketDecodeResult.Decoded).envelope + return NetworkPacket.fromEnvelope(source, envelope) + } + assertEquals(ServerConnections.Routed(b, false), connections.route(packet(second.first.localAddress), clients)) + assertEquals(0, a.netchan.incomingSequence) + assertEquals(1, b.netchan.incomingSequence) + assertNull(connections.route(packet(first.second.localAddress), clients)) + assertNull(connections.route(packet(LoopbackLink().first.localAddress), clients)) + assertEquals(ServerConnections.Admission.Slot(1, true), + connections.selectSlot(clients, second.first.localAddress, 7, 0, 500f)) + assertEquals(ServerConnections.Admission.Full, + connections.selectSlot(clients, first.second.localAddress, 7, 0, 500f)) } @Test @@ -86,19 +109,19 @@ class ServerConnectionsTest { val clients = listOf(client) assertNull(connections.route(packet(1, 20000, qport = 8), clients)) assertNull(connections.route(packet(1, 20000, host = 2), clients)) - assertEquals(10000, client.netchan.remoteAddress.port) + assertEquals(10000, (client.netchan.remoteAddress as NetworkAddress.Udp).port) assertEquals(ServerConnections.Routed(client, true), connections.route(packet(2, 20000), clients)) - assertEquals(20000, client.netchan.remoteAddress.port) + assertEquals(20000, (client.netchan.remoteAddress as NetworkAddress.Udp).port) assertNull(connections.route(packet(1, 30000), clients)) assertNull(connections.route(packet(2, 30000), clients)) - assertEquals(20000, client.netchan.remoteAddress.port) + assertEquals(20000, (client.netchan.remoteAddress as NetworkAddress.Udp).port) assertEquals(2, client.netchan.incomingSequence) assertEquals(ServerConnections.Routed(client, false), connections.route(packet(3, 20000), clients)) client.state = ClientStates.CS_ZOMBIE assertNotNull(connections.route(packet(4, 20000), clients)) client.state = ClientStates.CS_FREE assertNull(connections.route(packet(5, 40000), clients)) - assertEquals(20000, client.netchan.remoteAddress.port) + assertEquals(20000, (client.netchan.remoteAddress as NetworkAddress.Udp).port) } } } diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt index 469282b2..82f4acde 100644 --- a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -7,7 +7,6 @@ import jake2.qcommon.network.transport.* import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.runtime.PacketNetwork import jake2.qcommon.network.protocol.PacketCodec -import jake2.qcommon.network.netadr_t import jake2.qcommon.network.messages.client.UserInfoMessage import jake2.qcommon.network.messages.server.DisconnectMessage import jake2.qcommon.Defines @@ -54,7 +53,7 @@ class ServerNetworkTest { fun connection(qport: Int) = ClientNetworkInfo(peer.network.newChannel()).apply { state = ClientStates.CS_CONNECTED userinfo = "before" - netchan.setup(netadr_t(), qport) + netchan.setup(netchan.remoteAddress, qport) } val bad = connection(7) val healthy = connection(8) From 4e80338598e9d10e8b8d91a9e8e81b91beac45b6 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 09:39:35 +0100 Subject: [PATCH 50/57] Consolidate Kotlin networking settings and inject live configuration --- .../jake2/server/CakeHostApplicationTest.kt | 4 +- .../kotlin/jake2/server/CakeSessionPeer.kt | 4 +- .../jake2/server/EmbeddedCakeHostTest.kt | 4 +- .../jake2/server/EmbeddedCakeSavesTest.kt | 4 +- .../jake2/server/GameContinuationTest.kt | 4 +- .../jake2/server/HostClientAccountingTest.kt | 2 +- .../kotlin/jake2/server/HostNetworkFixture.kt | 4 +- .../server/HostSessionPreparationTest.kt | 2 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 10 +- .../org/demoth/cake/network/ClientNetwork.kt | 12 +- .../cake/network/ClientHandshakeTest.kt | 2 + .../demoth/cake/network/ClientNetworkTest.kt | 4 +- .../java/jake2/dedicated/Jake2Dedicated.java | 4 +- .../jake2/dedicated/DedicatedLifetimeTest.kt | 2 +- .../java/jake2/qcommon/network/Netchan.java | 122 ------------------ .../qcommon/network/NetworkSettings.java | 21 --- .../jake2/qcommon/network/NetworkSettings.kt | 80 ++++++++++++ .../qcommon/network/protocol/PacketCodec.kt | 8 +- .../network/protocol/ReliableChannel.kt | 18 +++ .../qcommon/network/runtime/PacketNetwork.kt | 31 +++-- .../java/jake2/qcommon/CommonCvarsTest.java | 14 +- .../qcommon/network/NetPacketReceiveTest.java | 2 +- .../network/NetSocketOwnershipTest.java | 2 +- .../jake2/qcommon/network/NetchanTest.java | 7 +- .../qcommon/network/OwnedNetworkPair.java | 6 +- .../kotlin/jake2/qcommon/OutputFailureTest.kt | 7 +- .../network/runtime/NetworkSettingsTest.kt | 113 ++++++++++++++++ .../network/runtime/PacketNetworkTest.kt | 15 ++- .../main/kotlin/jake2/server/ServerHost.kt | 3 +- .../main/kotlin/jake2/server/ServerNetwork.kt | 10 +- .../kotlin/jake2/server/ServerNetworkTest.kt | 8 +- 31 files changed, 312 insertions(+), 217 deletions(-) delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/Netchan.java delete mode 100644 qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java create mode 100644 qcommon/src/main/kotlin/jake2/qcommon/network/NetworkSettings.kt create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkSettingsTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index a1d8f57c..32b7b85d 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -14,7 +14,7 @@ import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find import jake2.qcommon.vfs.EngineWriteRoot -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.transport.LoopbackLink import jake2.qcommon.network.transport.PacketBytes import jake2.qcommon.network.protocol.PacketCodec @@ -55,7 +55,7 @@ class CakeHostApplicationTest { Cvar.getInstance().clear() MainCommon.registerCvars(false) CakeCvars.registerAll() - Netchan.Netchan_Init() + NetworkSettings.registerCvars() val cvars = Cvar.getInstance() cvars.find("port").forceSet(0) cvars.find("clientport").forceSet(0) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt index f77dfee4..eb1025d2 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt @@ -9,7 +9,7 @@ import jake2.qcommon.MainCommon import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import org.demoth.cake.Cake import org.demoth.cake.CakeStartupBootstrap import org.demoth.cake.fullgame.EmbeddedCakeHost @@ -30,7 +30,7 @@ object CakeSessionPeer { val deadline = System.nanoTime() + 60_000_000_000L if (args[0] == "server") { MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() Cvar.getInstance().find("port").forceSet(0) val host = EmbeddedCakeHost() try { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 9faa5175..3e9a14df 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -6,7 +6,7 @@ import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cmd import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.messages.server.DownloadMessage import jake2.qcommon.network.messages.server.ServerMessage import jake2.qcommon.vfs.EngineWriteRoot @@ -38,7 +38,7 @@ class EmbeddedCakeHostTest { cvars.clear() Globals.server_state = ServerStates.SS_DEAD MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() cvars.find("port").forceSet(0) cvars.find("clientport").forceSet(0) previousRoot = EngineWriteRoot.path() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt index a53f009e..d2dba8c9 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -6,7 +6,7 @@ import jake2.qcommon.MainCommon import jake2.qcommon.ServerStates import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.vfs.EngineWriteRoot import jake2.server.save.ServerSaveFiles import org.demoth.cake.fullgame.EmbeddedCakeHost @@ -32,7 +32,7 @@ class EmbeddedCakeSavesTest { cvars.clear() Globals.server_state = ServerStates.SS_DEAD MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() cvars.find("port").forceSet(0) cvars.find("clientport").forceSet(0) host = EmbeddedCakeHost(root) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 75703040..50f3954b 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -4,7 +4,7 @@ import jake2.qcommon.* import jake2.qcommon.exec.Cbuf import jake2.qcommon.exec.Cvar import jake2.qcommon.exec.find -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.messages.server.StuffTextMessage import jake2.qcommon.vfs.* @@ -30,7 +30,7 @@ class GameContinuationTest { Globals.server_state = ServerStates.SS_DEAD Cvar.getInstance().clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 20cdfa42..2ba2eb3e 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -57,7 +57,7 @@ class HostClientAccountingTest { Globals.server_state = ServerStates.SS_DEAD cvars.clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) EngineWriteRoot.setRoot(root) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt index bb8d9a91..31a815ba 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt @@ -1,5 +1,7 @@ package jake2.server +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.CvarNetworkSettings import jake2.qcommon.Defines import jake2.qcommon.network.messages.NetworkPacket import jake2.qcommon.network.protocol.* @@ -12,7 +14,7 @@ internal class HostNetworkFixture : AutoCloseable { val serverTransport = NetworkTransport(link.second, link.first.localAddress) val clientTransport = NetworkTransport(link.first, link.second.localAddress) val server = TransportServerNetwork(serverTransport) - val client = PacketNetwork(clientTransport, NetworkRole.CLIENT) + val client = PacketNetwork(clientTransport, NetworkRole.CLIENT, CvarNetworkSettings(Cvar.getInstance())) /** Inject a programming failure at send time without corrupting connection address state. */ fun failTransmissions(client: ClientNetworkInfo) { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index be9286c7..a9a9de5b 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -28,7 +28,7 @@ class HostSessionPreparationTest { Globals.server_state = ServerStates.SS_DEAD cvars.clear() MainCommon.registerCvars(false) - jake2.qcommon.network.Netchan.Netchan_Init() + jake2.qcommon.network.NetworkSettings.registerCvars() cvars.find("port").forceSet(0) EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, true, false, false, emptyList(), setOf("pak"))) 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 0c4b64df..7dbfad6e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -25,7 +25,7 @@ import jake2.qcommon.exec.find import org.demoth.cake.network.ClientNetwork import org.demoth.cake.network.ClientHandshake import jake2.qcommon.network.protocol.MessageDecodeResult -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.NetworkPacket @@ -200,7 +200,7 @@ class Cake( private val uiSize = Cvar.getInstance().find("cl_ui_size") init { - Netchan.Netchan_Init() + NetworkSettings.registerCvars() } override fun create() { @@ -1053,7 +1053,7 @@ class Cake( // The connect payload already includes current userinfo, so consume the legacy dirty flag. @Suppress("DEPRECATION") private fun connectPayload(challenge: Int): String { - val port = Cvar.getInstance().find("qport").typedValue + val port = network.qport Globals.userinfo_modified = false return " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" } @@ -1081,7 +1081,7 @@ class Cake( networkDebugSampler.recordInbound(netchan.lastReceivedSize, Globals.curtime) when (val body = networkPacket.parseBodyFromServer()) { is MessageDecodeResult.Decoded -> { - if (Netchan.printNetwork?.typedValue == true) body.messages.forEach { message -> + if (network.logMessages) body.messages.forEach { message -> if (message !is FrameHeaderMessage && message !is PlayerInfoMessage && message !is PacketEntitiesMessage) Com.Printf("Network: Received server msg: $message\n") } @@ -1325,7 +1325,7 @@ class Cake( ConnectionlessCommand.client_connect -> { if (!handshake.acceptConnection(packet.from)) return networkState = CONNECTED - netchan.setup(packet.from, Cvar.getInstance().find("qport").typedValue) + netchan.setup(packet.from, network.qport) netchan.queueReliable(StringCmdMessage(StringCmdMessage.NEW)) Com.Println("Connected!") } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt index 9b495967..a7c10f4c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -1,6 +1,7 @@ package org.demoth.cake.network import jake2.qcommon.network.* +import jake2.qcommon.exec.Cvar import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.transport.AddressResolver import jake2.qcommon.network.messages.ConnectionlessCommand @@ -11,6 +12,8 @@ import jake2.qcommon.network.runtime.NetworkRole /** Client transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ClientNetwork : AutoCloseable { + val logMessages: Boolean + val qport: Int val localPeer: NetworkAddress.Local fun resolveAddress(text: String, defaultPort: Int): NetworkAddress? = AddressResolver.resolve(text, defaultPort, localPeer) fun configure(multiplayer: Boolean): Boolean @@ -22,8 +25,13 @@ interface ClientNetwork : AutoCloseable { } /** Owns the supplied transport; connections borrow it through the shared packet adapter. */ -class TransportClientNetwork(transport: NetworkTransport) : ClientNetwork { - private val packets = PacketNetwork(transport, NetworkRole.CLIENT) +class TransportClientNetwork( + transport: NetworkTransport, + private val settings: NetworkSettings = CvarNetworkSettings(Cvar.getInstance()), +) : ClientNetwork { + private val packets = PacketNetwork(transport, NetworkRole.CLIENT, settings) + override val logMessages: Boolean get() = settings.logMessages + override val qport: Int get() = settings.qport override val localPeer: NetworkAddress.Local get() = packets.localPeer override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt index 8f02d78c..da590cc4 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt @@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test class ClientHandshakeTest { private data class Sent(val address: NetworkAddress, val command: ConnectionlessCommand, val payload: String) private class Network : ClientNetwork { + override val qport = 0 + override val logMessages = false override val localPeer = jake2.qcommon.network.transport.LoopbackLink().second.localAddress val sent = mutableListOf() val binds = mutableListOf() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt index 8c701f2b..62504880 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt @@ -1,7 +1,7 @@ package org.demoth.cake.network import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.transport.* @@ -14,7 +14,7 @@ class ClientNetworkTest { fun `client falls back from occupied UDP port and retains local transport on stop`() { val cvars = Cvar.getInstance() cvars.clear() - Netchan.Netchan_Init() + NetworkSettings.registerCvars() try { LoopbackLink().use { link -> val transport = NetworkTransport(link.first, link.second.localAddress) diff --git a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index 0535071b..6fe1f070 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -2,7 +2,7 @@ import jake2.qcommon.Com; import jake2.qcommon.MainCommon; -import jake2.qcommon.network.Netchan; +import jake2.qcommon.network.NetworkSettings; import jake2.qcommon.exec.Cbuf; import jake2.qcommon.exec.Cmd; import jake2.qcommon.exec.Cvar; @@ -59,7 +59,7 @@ public static void main(String[] args) { // // init commands and vars - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); runHost(args1); } catch (longjmpException e) { throw new IllegalStateException("Error during dedicated initialization", e); diff --git a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt index 360b55ef..53b6f5a0 100644 --- a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -29,7 +29,7 @@ class DedicatedLifetimeTest { Cvar.getInstance().clear() MainCommon.registerCvars(true) Cmd.Init() - jake2.qcommon.network.Netchan.Netchan_Init() + jake2.qcommon.network.NetworkSettings.registerCvars() Cvar.getInstance().find("port").forceSet(0) } diff --git a/qcommon/src/main/java/jake2/qcommon/network/Netchan.java b/qcommon/src/main/java/jake2/qcommon/network/Netchan.java deleted file mode 100644 index b4b5b86d..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/Netchan.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * NetChannel.java - * Copyright (C) 2003 - */ -/* - Copyright (C) 1997-2001 Id Software, Inc. - - This program is free software; you can redistribute it and/or - modify it under the terms of the GNU General Public License - as published by the Free Software Foundation; either version 2 - of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - - See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - - */ -package jake2.qcommon.network; - -import jake2.qcommon.Defines; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.exec.CvarTypes; -import jake2.qcommon.exec.cvar_t; -import jake2.qcommon.sys.Timer; - -/** - * Netchan - */ -public final class Netchan { - - /* - * packet header - * ------------- - * 1 does this message contains a reliable payload - * 31 sequence - * 1 acknowledge receipt of * even/odd message - * 31 acknowledge sequence - * 16 qport - * - * The remote connection never knows if it missed a reliable message, the - * local side detects that it has been dropped by seeing a sequence - * acknowledge higher thatn the last reliable sequence, but without the - * correct even/odd bit for the reliable set. - * - * If the sender notices that a reliable message has been dropped, it will - * be retransmitted. It will not be retransmitted again until a message - * after the retransmit has been acknowledged and the reliable still failed - * to get there. - * - * if the sequence number is -1, the packet should be handled without a - * netcon (Connectionless/out-of-band packet) - * - * The reliable message can be added to at any time by doing MSG_Write* - * (&netchan.message, ). - * - * If the message buffer is overflowed, either by a single message, or by - * multiple frames worth piling up while the last reliable transmit goes - * unacknowledged, the netchan signals a fatal error. - * - * Reliable messages are always placed first in a packet, then the - * unreliable message is included if there is sufficient room. - * - * To the receiver, there is no distinction between the reliable and - * unreliable parts of the message, they are just processed out as a single - * larger message. - * - * Illogical packet sequence numbers cause the packet to be dropped, but do - * not kill the connection. This, combined with the tight window of valid - * reliable acknowledgement numbers provides protection against malicious - * address spoofing. - * - * - * The qport field is a workaround for bad address translating routers that - * sometimes remap the client's source port on a packet during gameplay. - * - * If the base part of the net address matches and the qport matches, then - * the channel matches even if the IP port differs. The IP port should be - * updated after accepting the sequenced packet and before sending out any replies. - * - * - * If there is no information that needs to be transfered on a given frame, - * such as during the connection stage while waiting for the client to load, - * then a packet only needs to be delivered if there is something in the - * unacknowledged reliable - */ - - public static cvar_t showpackets; - - public static cvar_t showdrop; - - public static cvar_t qport; - - public static cvar_t printNetwork; - - /** - * Netchan_Init. - * - */ - public static void Netchan_Init() { - - Cvar cvars = Cvar.getInstance(); - NetworkSettings.registerCvars(); - showpackets = cvars.register("showpackets", CvarTypes.BOOLEAN, false, 0, "Log packet sizes, sequences and acknowledgements"); - showdrop = cvars.register("showdrop", CvarTypes.BOOLEAN, false, 0, "Log dropped or out-of-order packets"); - printNetwork = cvars.register("sv_print_network", CvarTypes.BOOLEAN, false, 0, "Log decoded incoming network messages"); - // Reinitializing channels retains the process's original random qport declaration. - qport = cvars.isRegistered("qport") ? cvars.find("qport", Integer.class) - : cvars.register("qport", new CvarTypes.IntegerType(0, 65535), - Timer.Milliseconds() & 0xffff, Defines.CVAR_NOSET, - "Client connection identifier for NAT port changes; not a UDP socket port"); - } - - - - -} diff --git a/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java b/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java deleted file mode 100644 index 41f773a3..00000000 --- a/qcommon/src/main/java/jake2/qcommon/network/NetworkSettings.java +++ /dev/null @@ -1,21 +0,0 @@ -package jake2.qcommon.network; - -import jake2.qcommon.Defines; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.exec.CvarTypes; - -/** Network configuration declarations; registering settings never creates transport resources. */ -public final class NetworkSettings { - private NetworkSettings() {} - - /** - * Declare protected network settings after startup config has supplied early values. - */ - public static void registerCvars() { - Cvar cvars = Cvar.getInstance(); - cvars.register("port", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_SERVER, Defines.CVAR_NOSET, "Server UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); - cvars.register("ip", CvarTypes.STRING, "localhost", Defines.CVAR_NOSET, "Local bind address; empty or localhost binds all interfaces"); - cvars.register("clientport", new CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_CLIENT, Defines.CVAR_NOSET, "Client UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent"); - } - -} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/NetworkSettings.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/NetworkSettings.kt new file mode 100644 index 00000000..ff19e73f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/NetworkSettings.kt @@ -0,0 +1,80 @@ +/* + * NetChannel.java + * Copyright (C) 2003 + */ +/* + Copyright (C) 1997-2001 Id Software, Inc. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + */ +package jake2.qcommon.network + +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.CvarTypes +import jake2.qcommon.sys.Timer +import java.util.function.IntSupplier + +/** Live configuration for one network owner; reading settings never creates transport resources. */ +interface NetworkSettings { + val bindAddress: String + val serverPort: Int + val clientPort: Int + val qport: Int + val logPackets: Boolean + val logDrops: Boolean + val logMessages: Boolean + + companion object { + /** Declare protected settings after startup config has supplied early values. */ + @JvmStatic @JvmOverloads + fun registerCvars(cvars: Cvar = Cvar.getInstance(), nextQport: IntSupplier = IntSupplier { Timer.Milliseconds() }): NetworkSettings { + cvars.register("port", CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_SERVER, Defines.CVAR_NOSET, + "Server UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent") + cvars.register("ip", CvarTypes.STRING, "localhost", Defines.CVAR_NOSET, + "Local bind address; empty or localhost binds all interfaces") + cvars.register("clientport", CvarTypes.IntegerType(Defines.PORT_ANY, 65535), Defines.PORT_CLIENT, Defines.CVAR_NOSET, + "Client UDP port; 0 lets the OS choose, -1 is the legacy wildcard-bind equivalent") + cvars.register("showpackets", CvarTypes.BOOLEAN, false, 0, "Log packet sizes, sequences and acknowledgements") + cvars.register("showdrop", CvarTypes.BOOLEAN, false, 0, "Log dropped or out-of-order packets") + cvars.register("sv_print_network", CvarTypes.BOOLEAN, false, 0, "Log decoded incoming network messages") + // Reinitializing networks retains the process's original random qport declaration. + if (cvars.isRegistered("qport")) cvars.find("qport", Int::class.javaObjectType) + else cvars.register("qport", CvarTypes.IntegerType(0, 65535), + nextQport.asInt and 0xffff, Defines.CVAR_NOSET, + "Client connection identifier for NAT port changes; not a UDP socket port") + return CvarNetworkSettings(cvars) + } + } +} + +/** + * Cvar-backed settings at the engine composition boundary. Construction does not declare settings: + * an application may create its network owners before startup config is loaded and registered. + * Resolve through the supplied registry on each read, so live edits and registry resets cannot leave + * a network holding stale Cvar handles. Unregistered diagnostics remain disabled during startup. + */ +class CvarNetworkSettings(private val cvars: Cvar) : NetworkSettings { + override val bindAddress: String get() = cvars.find("ip", String::class.java).typedValue + override val serverPort: Int get() = cvars.find("port", Int::class.javaObjectType).typedValue + override val clientPort: Int get() = cvars.find("clientport", Int::class.javaObjectType).typedValue + override val qport: Int get() = cvars.find("qport", Int::class.javaObjectType).typedValue + override val logPackets: Boolean get() = flag("showpackets") + override val logDrops: Boolean get() = flag("showdrop") + override val logMessages: Boolean get() = flag("sv_print_network") + private fun flag(name: String): Boolean = cvars.isRegistered(name) && cvars.find(name, Boolean::class.javaObjectType).typedValue +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt index 398501a9..b5390efc 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt @@ -5,7 +5,13 @@ import jake2.qcommon.network.transport.PacketBytes import java.nio.ByteBuffer import java.nio.ByteOrder -/** Stateless envelope encoding/decoding. No sockets, Cvars, logging, or mutable channel state. */ +/** + * Stateless envelope encoding/decoding. No sockets, Cvars, logging, or mutable channel state. + * + * Packet header: one reliable-payload flag plus 31 sequence bits, one even/odd reliable receipt flag + * plus 31 acknowledgement bits, and a 16-bit qport only on client-to-server packets. Sequence -1 + * marks a connectionless/out-of-band packet, which is handled without a connection channel. + */ object PacketCodec { /** Encode the wire header and already-serialized body without changing channel state. */ @JvmStatic diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt index 02c33484..70e9e239 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt @@ -48,6 +48,24 @@ sealed interface PrepareSendResult { /** Quake II reliable channel state, confined to one owner thread. * Times are elapsed monotonic milliseconds supplied by the caller. Every prepared * transmission must be completed before further channel operations, even on I/O failure. + * + * The receiver does not separately detect a missed reliable payload. The sender detects it when + * an acknowledgement advances beyond the last reliable sequence without the correct even/odd bit. + * It retransmits then, and waits for acknowledgement of a later packet before retrying again. + * + * Reliable bytes are always first; an unreliable batch follows only if it fits. The receiver + * processes both as one larger body, without a reliable/unreliable distinction. Out-of-order + * sequence numbers drop the packet without killing the connection; these rules are not authentication. + * + * The connection adapter can stage reliable messages at any time. A single oversized message or + * multiple frames piling up while reliable data is unacknowledged cause that adapter to signal a + * fatal overflow. Queued cancellation affects unsent work; transmitted bytes await acknowledgement. + * Idle connection/loading frames can send header-only keepalives or retransmit outstanding data. + * + * Qport works around routers remapping a client's UDP source port during gameplay. The router + * matches the source IP and qport even when the UDP port changes, then commits the new port only + * after accepting sequence state and before sending replies. Local peers instead retain their + * full link and endpoint identity. */ class ReliableChannel @JvmOverloads constructor( private val role: ChannelRole, diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index 23ffd194..e484d285 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -2,8 +2,7 @@ package jake2.qcommon.network.runtime import jake2.qcommon.Com import jake2.qcommon.Defines -import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.ConnectionChannel import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkPacket @@ -18,9 +17,11 @@ import java.nio.ByteOrder enum class NetworkRole { CLIENT, SERVER } /** Shared engine adapter: bind policy, packet envelopes and transport lifetime for one owner. */ -class PacketNetwork( +class PacketNetwork @JvmOverloads constructor( private val transport: NetworkTransport, private val role: NetworkRole, + private val settings: NetworkSettings, + private val log: (String) -> Unit = { Com.Printf(it) }, ) : AutoCloseable { companion object { /** Maximum datagrams consumed per receive pass, including malformed and dropped traffic. */ @@ -40,8 +41,8 @@ class PacketNetwork( return ConnectionChannel( if (role == NetworkRole.CLIENT) ChannelRole.CLIENT else ChannelRole.SERVER, transport.localPeer, transport::send, - logPackets = { Netchan.showpackets?.typedValue == true }, - log = { Com.Printf(it) }, + logPackets = { settings.logPackets }, + log = log, clockMillis = { System.nanoTime() / 1_000_000L }, ) } @@ -57,10 +58,8 @@ class PacketNetwork( return true } if (transport.udpEndpoint?.isOpen == true) return true - val cvars = Cvar.getInstance() - val ip = cvars.find("ip", String::class.java).typedValue - val portName = if (role == NetworkRole.CLIENT) "clientport" else "port" - val port = cvars.find(portName, Int::class.javaObjectType).typedValue + val ip = settings.bindAddress + val port = (if (role == NetworkRole.CLIENT) settings.clientPort else settings.serverPort) .let { if (it == Defines.PORT_ANY) 0 else it } return try { val bindIp = if (ip.isEmpty() || ip == "localhost") null else InetAddress.getByName(ip) @@ -75,7 +74,7 @@ class PacketNetwork( transport.replaceUdp(endpoint) true } catch (error: IOException) { - Com.Println("$label UDP bind error: $error") + log("$label UDP bind error: $error\n") false } } @@ -90,19 +89,19 @@ class PacketNetwork( transport.poll(budget) { received -> decode(received)?.let(consume) } private fun decode(received: ReceiveResult): NetworkPacket? { - if (received is ReceiveResult.Failed) Com.Println("$label receive error: ${received.cause}") + if (received is ReceiveResult.Failed) log("$label receive error: ${received.cause}\n") if (received !is ReceiveResult.Received) return null val datagram = received.datagram val decoded = PacketCodec.decode(datagram.payload, direction) if (decoded is PacketDecodeResult.Rejected) { - if (Netchan.showdrop?.typedValue == true) - Com.Println("$label dropped packet from ${datagram.source}: ${decoded.reason}") + if (settings.logDrops) + log("$label dropped packet from ${datagram.source}: ${decoded.reason}\n") return null } val packet = NetworkPacket.fromEnvelope(datagram.source, (decoded as PacketDecodeResult.Decoded).envelope) - if (packet.isConnectionless && Netchan.printNetwork?.typedValue == true) { + if (packet.isConnectionless && settings.logMessages) { val peer = if (role == NetworkRole.CLIENT) "server" else "client" - Com.Printf("Network: Received from $peer connectionless: ${packet.connectionlessMessage}\n") + log("Network: Received from $peer connectionless: ${packet.connectionlessMessage}\n") } return packet } @@ -113,7 +112,7 @@ class PacketNetwork( require(text.size <= Defines.MAX_MSGLEN - 4) val bytes = ByteBuffer.allocate(text.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(-1).put(text).array() val result = transport.send(to, PacketBytes.copyOf(bytes)) - if (result is SendResult.Failed) Com.Println("$label send error: ${result.cause}") + if (result is SendResult.Failed) log("$label send error: ${result.cause}\n") } /** Session boundary: keep the local endpoint and final messages queued for its peer. */ diff --git a/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java b/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java index 87614602..fb4b11b3 100644 --- a/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java +++ b/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java @@ -2,7 +2,7 @@ import jake2.qcommon.exec.Cbuf; import jake2.qcommon.exec.Cvar; -import jake2.qcommon.network.Netchan; +import jake2.qcommon.network.NetworkSettings; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,18 +44,18 @@ void protectedNetworkDeclarationsConsumeStartupConfig() { Cvar.Init(); Cbuf.AddText("set ip 127.0.0.1\nset port 28000\nset clientport -1\nset qport 65535\n"); Cbuf.Execute(); - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); assertEquals("127.0.0.1", cvars.find("ip", String.class).getTypedValue()); assertEquals(28000, cvars.find("port", Integer.class).getTypedValue()); assertEquals(-1, cvars.find("clientport", Integer.class).getTypedValue()); - assertEquals(65535, Netchan.qport.getTypedValue()); + assertEquals(65535, cvars.find("qport", Integer.class).getTypedValue()); cvars.setResult("port", "29000"); assertEquals(28000, cvars.find("port", Integer.class).getTypedValue()); cvars.forceSetResult("qport", "65536"); - assertEquals(65535, Netchan.qport.getTypedValue()); - Netchan.Netchan_Init(); - assertEquals(65535, Netchan.qport.getTypedValue()); + assertEquals(65535, cvars.find("qport", Integer.class).getTypedValue()); + NetworkSettings.registerCvars(); + assertEquals(65535, cvars.find("qport", Integer.class).getTypedValue()); cvars.setResult("sv_print_network", "true"); - assertTrue(Netchan.printNetwork.getTypedValue()); + assertTrue(cvars.find("sv_print_network", Boolean.class).getTypedValue()); } } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java index c26c86b5..907b5f92 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -31,7 +31,7 @@ class NetPacketReceiveTest { @BeforeEach void setup() { Cvar.getInstance().clear(); - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); pair = new OwnedNetworkPair(); } diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java index aec24fa4..c451c55a 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -22,7 +22,7 @@ class NetSocketOwnershipTest { void setup() { Cvar.getInstance().clear(); MainCommon.registerCvars(false); - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); pair = new OwnedNetworkPair(); Cvar.getInstance().find("port", Integer.class).forceSet(0); Cvar.getInstance().find("clientport", Integer.class).forceSet(0); diff --git a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java index 95c6e036..58a7368e 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -1,5 +1,6 @@ package jake2.qcommon.network; +import jake2.qcommon.network.CvarNetworkSettings; import jake2.qcommon.Globals; import jake2.qcommon.network.protocol.ChannelRole; import jake2.qcommon.network.transport.SendResult; @@ -34,10 +35,10 @@ void setUp() { var link = new LoopbackLink(); client = new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()); server = new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()); - serverPackets = new PacketNetwork(server, NetworkRole.SERVER); + serverPackets = new PacketNetwork(server, NetworkRole.SERVER, new CvarNetworkSettings(Cvar.getInstance())); Cvar.getInstance().clear(); Globals.curtime = 0; - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); } @AfterEach @@ -48,7 +49,7 @@ void cleanup() throws Exception { @Test void outgoingClientPacketUsesItsSetupQport() { - Netchan.qport.forceSet(77); + Cvar.getInstance().find("qport", Integer.class).forceSet(77); var channel = new ConnectionChannel(ChannelRole.CLIENT, client.getLocalPeer(), client::send, () -> (long) Globals.curtime); channel.setup(channel.getRemoteAddress(), 1234); assertTrue(channel.transmit(null)); diff --git a/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java index ab9decb4..9ddaeaf3 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java +++ b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java @@ -1,5 +1,7 @@ package jake2.qcommon.network; +import jake2.qcommon.exec.Cvar; +import jake2.qcommon.network.CvarNetworkSettings; import jake2.qcommon.network.runtime.NetworkRole; import jake2.qcommon.network.runtime.NetworkTransport; import jake2.qcommon.network.runtime.PacketNetwork; @@ -10,8 +12,8 @@ final class OwnedNetworkPair implements AutoCloseable { private final LoopbackLink link = new LoopbackLink(); final NetworkTransport clientTransport = new NetworkTransport(link.getFirst(), link.getSecond().getLocalAddress()); final NetworkTransport serverTransport = new NetworkTransport(link.getSecond(), link.getFirst().getLocalAddress()); - final PacketNetwork client = new PacketNetwork(clientTransport, NetworkRole.CLIENT); - final PacketNetwork server = new PacketNetwork(serverTransport, NetworkRole.SERVER); + final PacketNetwork client = new PacketNetwork(clientTransport, NetworkRole.CLIENT, new CvarNetworkSettings(Cvar.getInstance())); + final PacketNetwork server = new PacketNetwork(serverTransport, NetworkRole.SERVER, new CvarNetworkSettings(Cvar.getInstance())); void clearLocalTraffic() { link.getFirst().clearIncoming(); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt index 96a8c0ef..0851710f 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -1,5 +1,6 @@ package jake2.qcommon +import jake2.qcommon.network.CvarNetworkSettings import jake2.qcommon.network.runtime.* import jake2.qcommon.network.transport.LocalSide import jake2.qcommon.network.messages.ConnectionlessCommand @@ -7,7 +8,7 @@ import jake2.qcommon.network.transport.NetworkAddress import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings class OutputFailureTest { @Test @@ -45,11 +46,11 @@ class OutputFailureTest { fun `oversized connectionless output propagates without closing the socket`() { val cvars = Cvar.getInstance() cvars.clear() - Netchan.Netchan_Init() + NetworkSettings.registerCvars() cvars.find("port", Int::class.javaObjectType).forceSet(0) val transport = NetworkTransport.standalone(LocalSide.SECOND) try { - PacketNetwork(transport, NetworkRole.SERVER).use { packets -> + PacketNetwork(transport, NetworkRole.SERVER, CvarNetworkSettings(Cvar.getInstance())).use { packets -> assertTrue(packets.configure(true)) val endpoint = transport.udpEndpoint!! val failure = assertThrows(IllegalArgumentException::class.java) { diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkSettingsTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkSettingsTest.kt new file mode 100644 index 00000000..cd6bdd7e --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkSettingsTest.kt @@ -0,0 +1,113 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.CvarNetworkSettings +import jake2.qcommon.network.NetworkSettings +import jake2.qcommon.network.transport.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class NetworkSettingsTest { + @Test + fun `registration consumes early values protects declarations and retains original qport seed`() { + val cvars = Cvar() + val settings = CvarNetworkSettings(cvars) + assertFalse(settings.logPackets) + assertFalse(settings.logDrops) + assertFalse(settings.logMessages) + assertFalse(cvars.isRegistered("port"), "Constructing settings must leave startup registration to its owner") + cvars.setResult("ip", "127.0.0.1") + cvars.setResult("port", "28000") + cvars.setResult("clientport", "-1") + cvars.setResult("qport", "65535") + NetworkSettings.registerCvars(cvars) { 1234 } + assertEquals("127.0.0.1", settings.bindAddress) + assertEquals(28000, settings.serverPort) + assertEquals(-1, settings.clientPort) + assertEquals(65535, settings.qport) + val declaredQport = cvars.find("qport", Int::class.javaObjectType) + assertEquals(1234, declaredQport.defaultValue) + assertEquals(Defines.CVAR_NOSET, declaredQport.flags) + NetworkSettings.registerCvars(cvars) { error("Repeated registration must retain the original seed") } + assertSame(declaredQport, cvars.find("qport", Int::class.javaObjectType)) + cvars.setResult("port", "29000") + cvars.setResult("qport", "12") + assertEquals(28000, settings.serverPort) + assertEquals(65535, settings.qport) + cvars.forceSetResult("qport", "65536") + cvars.forceSetResult("port", "-2") + assertEquals(65535, settings.qport) + assertEquals(28000, settings.serverPort) + } + + @Test + fun `live provider follows edits and registry reset without retaining stale handles`() { + val cvars = Cvar() + val settings = NetworkSettings.registerCvars(cvars) { -1 } + assertEquals(65535, settings.qport) + cvars.setResult("showpackets", "true") + cvars.setResult("showdrop", "true") + cvars.setResult("sv_print_network", "true") + assertTrue(settings.logPackets && settings.logDrops && settings.logMessages) + cvars.clear() + assertFalse(settings.logPackets || settings.logDrops || settings.logMessages) + NetworkSettings.registerCvars(cvars) { 7 } + assertEquals(7, settings.qport) + assertEquals(Defines.PORT_SERVER, settings.serverPort) + assertFalse(settings.logPackets || settings.logDrops || settings.logMessages) + } + + @Test + fun `owners bind and log through independent injected registries including live channel diagnostics`() { + val a = Cvar() + val b = Cvar() + val settingsA = NetworkSettings.registerCvars(a) { 11 } + val settingsB = NetworkSettings.registerCvars(b) { 22 } + a.forceSetResult("ip", "127.0.0.1") + b.forceSetResult("ip", "127.0.0.1") + a.forceSetResult("port", "0") + b.forceSetResult("port", "0") + val logsA = mutableListOf() + val logsB = mutableListOf() + LoopbackLink().use { linkA -> LoopbackLink().use { linkB -> + val transportA = NetworkTransport(linkA.second, linkA.first.localAddress) + val transportB = NetworkTransport(linkB.second, linkB.first.localAddress) + PacketNetwork(transportA, NetworkRole.SERVER, settingsA, logsA::add).use { networkA -> + PacketNetwork(transportB, NetworkRole.SERVER, settingsB, logsB::add).use { networkB -> + assertTrue(networkA.configure(true)) + assertTrue(networkB.configure(true)) + assertNotEquals(transportA.udpEndpoint!!.localAddress, transportB.udpEndpoint!!.localAddress) + val channelA = networkA.newChannel().apply { setup(networkA.localPeer, settingsA.qport) } + val channelB = networkB.newChannel().apply { setup(networkB.localPeer, settingsB.qport) } + channelA.transmit(null); channelB.transmit(null) + assertTrue(logsA.isEmpty() && logsB.isEmpty()) + a.setResult("showpackets", "true") + channelA.transmit(null); channelB.transmit(null) + assertTrue(logsA.single().contains("Netchan:")) + assertTrue(logsB.isEmpty()) + logsA.clear() + a.setResult("showdrop", "true") + val malformed = PacketBytes.copyOf(byteArrayOf(1)) + linkA.first.send(linkA.second.localAddress, malformed) + linkB.first.send(linkB.second.localAddress, malformed) + networkA.poll {}; networkB.poll {} + assertTrue(logsA.single().contains("dropped packet")) + assertTrue(logsB.isEmpty()) + logsA.clear() + a.setResult("sv_print_network", "true") + val ping = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray()) + linkA.first.send(linkA.second.localAddress, ping) + linkB.first.send(linkB.second.localAddress, ping) + networkA.poll {}; networkB.poll {} + assertTrue(logsA.single().contains("connectionless: ping")) + assertTrue(logsB.isEmpty()) + networkA.configure(false) + assertTrue(transportB.udpEndpoint!!.isOpen) + assertEquals(11, settingsA.qport) + assertEquals(22, settingsB.qport) + } + } + } } + } +} diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt index 2029268d..fc32cbe9 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -1,8 +1,9 @@ package jake2.qcommon.network.runtime +import jake2.qcommon.network.CvarNetworkSettings import jake2.qcommon.Defines import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.transport.* import org.junit.jupiter.api.Assertions.* @@ -18,7 +19,7 @@ class PacketNetworkTest { val budget = PacketNetwork.DEFAULT_POLL_BUDGET LoopbackLink(capacity = budget + 1).use { link -> val transport = NetworkTransport(link.first, link.second.localAddress) - PacketNetwork(transport, role).use { network -> + PacketNetwork(transport, role, CvarNetworkSettings(Cvar.getInstance())).use { network -> repeat(budget) { link.second.send(link.first.localAddress, PacketBytes.copyOf(byteArrayOf(1))) } val ping = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray()) link.second.send(link.first.localAddress, ping) @@ -38,7 +39,7 @@ class PacketNetworkTest { fun `poll continues past malformed packets within budget and bounds self-replenishing traffic`() { LoopbackLink().use { link -> val transport = NetworkTransport(link.first, link.second.localAddress) - PacketNetwork(transport, NetworkRole.CLIENT).use { network -> + PacketNetwork(transport, NetworkRole.CLIENT, CvarNetworkSettings(Cvar.getInstance())).use { network -> val ping = PacketBytes.copyOf(byteArrayOf(-1, -1, -1, -1) + "ping".toByteArray()) link.second.send(link.first.localAddress, PacketBytes.copyOf(byteArrayOf(1))) link.second.send(link.first.localAddress, ping) @@ -60,8 +61,8 @@ class PacketNetworkTest { LoopbackLink().use { link -> val clientTransport = NetworkTransport(link.first, link.second.localAddress) val serverTransport = NetworkTransport(link.second, link.first.localAddress) - PacketNetwork(clientTransport, NetworkRole.CLIENT).use { client -> - PacketNetwork(serverTransport, NetworkRole.SERVER).use { server -> + PacketNetwork(clientTransport, NetworkRole.CLIENT, CvarNetworkSettings(Cvar.getInstance())).use { client -> + PacketNetwork(serverTransport, NetworkRole.SERVER, CvarNetworkSettings(Cvar.getInstance())).use { server -> val clientChannel = client.newChannel() clientChannel.setup(clientChannel.remoteAddress, 1234) assertTrue(clientChannel.transmit(null)) @@ -96,10 +97,10 @@ class PacketNetworkTest { fun `server bind failure never falls back and legacy wildcard port works with explicit IP`() { val cvars = Cvar.getInstance() cvars.clear() - Netchan.Netchan_Init() + NetworkSettings.registerCvars() val transport = NetworkTransport.standalone(LocalSide.SECOND) try { - PacketNetwork(transport, NetworkRole.SERVER).use { server -> + PacketNetwork(transport, NetworkRole.SERVER, CvarNetworkSettings(Cvar.getInstance())).use { server -> DatagramSocket(0).use { occupied -> cvars.find("port", Int::class.javaObjectType).forceSet(occupied.localPort) assertFalse(server.configure(true)) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index b09a5a6e..d2330172 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -25,7 +25,6 @@ import jake2.qcommon.* import jake2.qcommon.exec.* import jake2.qcommon.exec.CvarTypes.ChoiceType import jake2.qcommon.network.protocol.MessageDecodeResult -import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage import jake2.qcommon.network.messages.client.* @@ -1168,7 +1167,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) dropClient(client) } is MessageDecodeResult.Decoded -> { - if (Netchan.printNetwork?.typedValue == true) body.messages.forEach { message -> + if (network.logMessages) body.messages.forEach { message -> if (message !is MoveMessage) Com.Printf("Network: Received client msg: $message\n") } handleClientMessages(client, body.messages) diff --git a/server/src/main/kotlin/jake2/server/ServerNetwork.kt b/server/src/main/kotlin/jake2/server/ServerNetwork.kt index 9789a760..37dccef3 100644 --- a/server/src/main/kotlin/jake2/server/ServerNetwork.kt +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -1,6 +1,7 @@ package jake2.server import jake2.qcommon.network.* +import jake2.qcommon.exec.Cvar import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.transport.AddressResolver import jake2.qcommon.network.messages.ConnectionlessCommand @@ -12,6 +13,7 @@ import jake2.qcommon.network.transport.LocalSide /** Server transport boundary. A stopped game retires UDP and incoming local traffic, not its peer. */ interface ServerNetwork : AutoCloseable { + val logMessages: Boolean val localPeer: NetworkAddress.Local fun resolveAddress(text: String, defaultPort: Int): NetworkAddress? = AddressResolver.resolve(text, defaultPort, localPeer) fun configure(multiplayer: Boolean): Boolean @@ -24,12 +26,16 @@ interface ServerNetwork : AutoCloseable { } /** Owns the supplied transport; connections borrow it through the shared packet adapter. */ -class TransportServerNetwork(private val transport: NetworkTransport) : ServerNetwork { +class TransportServerNetwork @JvmOverloads constructor( + private val transport: NetworkTransport, + private val settings: NetworkSettings = CvarNetworkSettings(Cvar.getInstance()), +) : ServerNetwork { companion object { @JvmStatic fun standalone() = TransportServerNetwork( NetworkTransport.standalone(LocalSide.SECOND)) } - private val packets = PacketNetwork(transport, NetworkRole.SERVER) + private val packets = PacketNetwork(transport, NetworkRole.SERVER, settings) + override val logMessages: Boolean get() = settings.logMessages override val localPeer: NetworkAddress.Local get() = packets.localPeer override fun newChannel() = packets.newChannel() override fun configure(multiplayer: Boolean) = packets.configure(multiplayer) diff --git a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt index 82f4acde..7b72c1e6 100644 --- a/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -2,7 +2,7 @@ package jake2.server import jake2.qcommon.MainCommon import jake2.qcommon.exec.Cvar -import jake2.qcommon.network.Netchan +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.transport.* import jake2.qcommon.network.runtime.NetworkTransport import jake2.qcommon.network.runtime.PacketNetwork @@ -47,7 +47,7 @@ class ServerNetworkTest { fun `malformed client body drops only that client without dispatching its valid prefix`() { Cvar.getInstance().clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() try { Peer().use { peer -> fun connection(qport: Int) = ClientNetworkInfo(peer.network.newChannel()).apply { @@ -80,7 +80,7 @@ class ServerNetworkTest { fun `Host packet pass bounds malformed and valid traffic and resumes next pass`() { Cvar.getInstance().clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() try { val budget = PacketNetwork.DEFAULT_POLL_BUDGET Peer(capacity = budget + 1).use { peer -> @@ -100,7 +100,7 @@ class ServerNetworkTest { fun `hosts reply on their injected links and closing one leaves the other running`() { Cvar.getInstance().clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() + NetworkSettings.registerCvars() try { Peer().use { first -> Peer().use { second -> first.ping() From f9d4004948694e5e1f41ad3c229a39daeef8e4e0 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 12:45:47 +0100 Subject: [PATCH 51/57] docs: network doc update --- info/Networking.md | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/info/Networking.md b/info/Networking.md index 2f91f6fb..81107e89 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -1,22 +1,30 @@ # Quake 2 Networking Communication Protocol +## Glossary + +- **Owner** — The client or server responsible for creating and closing its network resources. +- **Transport** (`NetworkTransport`) — Sends and receives datagrams through the owner's local and UDP endpoints. +- **Endpoint** (`DatagramEndpoint`) — One place where datagrams are sent and received: a UDP socket or a local queue. +- **Local link** (`LoopbackLink`) — A private pair of endpoints connecting a client and server inside one process. +- **Network** (`ClientNetwork` / `ServerNetwork`) — The application's interface for configuring network I/O, polling packets, and creating connections. +- **Packet network** (`PacketNetwork`) — Shared client/server implementation of binding, polling, and packet-envelope decoding. +- **Connection** (`ConnectionChannel`) — Communication with one peer, including its address and queued outgoing messages. +- **Reliable channel** (`ReliableChannel`) — Tracks packet sequences, acknowledgements, and reliable retransmissions for one connection. +- **Address** (`NetworkAddress`) — Identifies a UDP IP/port or an endpoint on a specific local link. +- **Datagram** — One independently delivered unit of bytes, together with its source address when received. +- **Packet** — A Quake II protocol unit carried by a datagram; either connectionless or sequenced. +- **Envelope** (`PacketEnvelope`) — The decoded outer packet structure, identifying its kind and header fields. +- **Message** (`ClientMessage` / `ServerMessage`) — One command or update inside a sequenced packet, such as movement or entity state. +- **Codec** — Converts protocol values to and from bytes; it does not own sockets or connections. +- **Handshake** (`ClientHandshake`) — The exchange used to establish a connection, including challenges and retries. +- **Session** — The lifetime of a connection to a server; it can span multiple map changes. + ## Transport ownership -Client and server controllers own separate `NetworkTransport` instances. Each role -configures only its own UDP endpoint; loopback communication needs no socket. A -client may fall back to an ephemeral UDP port when its configured port is occupied. -Applications compose these owners explicitly, replacing the former combined global -socket policy and `NET` facade. The archived client's API/ABI is not maintained; -the Quake II network wire protocol remains compatible. - -Local traffic uses a private `LoopbackLink`. Session stop discards incoming local -traffic while preserving final messages already queued for the peer. Map changes -retain reconnect traffic; clearing both local queues belongs at a whole-session -boundary. Closing an owner closes its resources independently of the peer's. - -Packet decoding has explicit, side-effect-free outcomes below the engine adapter. -Dedicated-server tick waiting currently uses a timed sleep rather than socket -readiness. Network ownership does not impose a global client/server sleep policy. +- Client and server each own a separate transport. +- Each transport manages its own UDP socket and one endpoint of the local link. +- Stopping or closing one side preserves messages already queued for the other side. +- Map changes preserve reconnect traffic; clearing both local queues requires an explicit session-level decision. ## NetworkPacket From 76c31e90e306a2166a9a99ce6c71e46d5d2ead13 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 14:42:53 +0100 Subject: [PATCH 52/57] Omit unchanged weapon model indices from player snapshots --- .../java/jake2/server/GameImportsTest.java | 37 +++++++++++++++ .../ClientEntityManagerFrameSelectionTest.kt | 3 ++ .../messages/server/PlayerStateCodec.kt | 4 +- .../network/protocol/PlayerStateCodecTest.kt | 46 ++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 5e31d1ab..31528f30 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -216,6 +216,43 @@ public void clientSnapshotsKeepIndependentGameHistory() { Assertions.assertNotEquals(0, ((EntityUpdate.Change) ((PacketEntitiesMessage) stale.get(2)).getUpdates().get(0)).getDelta().getFlags() & Defines.U_OLDORIGIN); } + @Test + public void weaponIndexDeltasFollowAcknowledgedFramesAcrossLossAndFullSnapshots() { + manager = new ServerHost(TransportServerNetwork.standalone()); + GameImportsImpl game = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + ClientNetworkInfo client = snapshotClient(game, 1); + var state = client.edict.getClient().getPlayerState(); + state.gunindex = 3; + state.gunframe = 1; + Assertions.assertEquals(3, playerUpdate(game, client, 1, -1).getWeaponIndex()); + + state.gunframe = 2; + PlayerInfoMessage animation = playerUpdate(game, client, 2, 1); + Assertions.assertNull(animation.getWeaponIndex()); + Assertions.assertEquals(2, animation.getWeaponFrame().getFrame()); + + state.gunindex = 7; + Assertions.assertEquals(7, playerUpdate(game, client, 3, 1).getWeaponIndex()); + // Frame 3 was lost: frame 4 must repeat the switch relative to acknowledged frame 1. + Assertions.assertEquals(7, playerUpdate(game, client, 4, 1).getWeaponIndex()); + Assertions.assertNull(playerUpdate(game, client, 5, 4).getWeaponIndex()); + + state.gunindex = 0; + Assertions.assertEquals(0, playerUpdate(game, client, 6, 5).getWeaponIndex()); + Assertions.assertNull(playerUpdate(game, client, 7, 6).getWeaponIndex()); + + state.gunindex = 7; + Assertions.assertEquals(7, playerUpdate(game, client, 8, 0).getWeaponIndex()); + // An expired acknowledgement forces a full snapshot even though its old model matched. + Assertions.assertEquals(7, playerUpdate(game, client, 8 + Defines.UPDATE_BACKUP, 8).getWeaponIndex()); + } + + private PlayerInfoMessage playerUpdate(GameImportsImpl game, ClientNetworkInfo client, int frame, int acknowledged) { + game.frameNum = frame; + client.lastReceivedFrame = acknowledged; + return (PlayerInfoMessage) List.copyOf(game.buildClientFrameMessages(client)).get(1); + } + private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { GameEntity entity = (GameEntity) game.gameExports.getEdict(1); entity.setClient(new GamePlayerInfo(0)); diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt index ca1426d3..7bbf65f4 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/ClientEntityManagerFrameSelectionTest.kt @@ -114,6 +114,7 @@ class ClientEntityManagerFrameSelectionTest { } receive(2, 1, PlayerInfoMessage.fromGame(initial, next)) val second = manager.currentFrame.playerstate + assertEquals(1, second.gunindex, "An omitted weapon index inherits the acknowledged model") assertArrayEquals(shortArrayOf(0, 16, 24), second.pmove.origin) assertArrayEquals(floatArrayOf(0f, 0f, 0f), second.viewoffset) assertArrayEquals(first.viewangles, second.viewangles) @@ -133,6 +134,8 @@ class ClientEntityManagerFrameSelectionTest { assertArrayEquals(first.pmove.origin, manager.currentFrame.playerstate.pmove.origin) assertArrayEquals(first.viewoffset, manager.currentFrame.playerstate.viewoffset) assertArrayEquals(first.stats, manager.currentFrame.playerstate.stats) + receive(5, 1, PlayerInfoMessage.fromGame(initial, initial.getClone().apply { gunindex = 0 })) + assertEquals(0, manager.currentFrame.playerstate.gunindex, "An explicit zero removes the weapon model") } finally { manager.dispose() } diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt index 557de698..240a6ccc 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt @@ -19,8 +19,8 @@ internal object PlayerStateCodec { viewOffset = if (different(current.viewoffset, previous.viewoffset)) quarterBytes(current.viewoffset) else null, viewAngles = if (different(current.viewangles, previous.viewangles)) angleShorts(current.viewangles) else null, kickAngles = if (different(current.kick_angles, previous.kick_angles)) quarterBytes(current.kick_angles) else null, - // Always sent, even if unchanged. - weaponIndex = current.gunindex and 255, + // Compare against the acknowledged reference, including resets to zero. + weaponIndex = if (current.gunindex != previous.gunindex) current.gunindex and 255 else null, // Gun offsets/angles are sent only when gunframe changes, preserving protocol-34 behavior. weaponFrame = if (current.gunframe != previous.gunframe) PlayerWeaponFrame(current.gunframe and 255, quarterBytes(current.gunoffset), quarterBytes(current.gunangles)) else null, blend = if (different(current.blend, previous.blend, 4)) PlayerBlend((current.blend[0] * 255).toInt() and 255, (current.blend[1] * 255).toInt() and 255, (current.blend[2] * 255).toInt() and 255, (current.blend[3] * 255).toInt() and 255) else null, diff --git a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt index db6c9d53..9ae30212 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt @@ -59,7 +59,10 @@ class PlayerStateCodecTest { for (change in changes) { val current = player_state_t().also { change(it, value) } val message = PlayerInfoMessage.fromGame(player_state_t(), current) - assertArrayEquals(write(LegacyPlayerInfoReference(player_state_t(), current)), write(message)) + // The original writer always includes gunindex; restore that one field when + // comparing all other field selection and quantization against its bytes. + val legacyCompatible = if (current.gunindex == 0) message.copy(weaponIndex = 0) else message + assertArrayEquals(write(LegacyPlayerInfoReference(player_state_t(), current)), write(legacyCompatible)) assertEquals(message, read(write(message))) } } @@ -69,7 +72,46 @@ class PlayerStateCodecTest { // Weapon offset/angle changes alone never add the weapon-frame group. val offsetsOnly = PlayerInfoMessage.fromGame(player_state_t(), player_state_t().apply { gunoffset[0] = 2f; gunangles[0] = 3f }) assertNull(offsetsOnly.weaponFrame) - assertEquals(PS_WEAPONINDEX, offsetsOnly.deltaFlags) + assertEquals(0, offsetsOnly.deltaFlags) + } + + @Test + fun `unchanged weapon models save one byte while animation advances and old readers accept omission`() { + for (index in listOf(0, 1, 127, 128, 255)) { + val previous = player_state_t().apply { gunindex = index; gunframe = 3 } + val current = previous.getClone().apply { gunframe = 4; gunoffset[0] = 1.25f } + val message = PlayerInfoMessage.fromGame(previous, current) + assertNull(message.weaponIndex) + assertEquals(0, message.deltaFlags and PS_WEAPONINDEX) + assertEquals(4, message.weaponFrame!!.frame) + val bytes = write(message) + assertEquals(write(LegacyPlayerInfoReference(previous, current)).size - 1, bytes.size) + assertEquals(message, read(bytes)) + val buffer = sizebuf_t(bytes.size).apply { writeBytes(bytes); readcount = 1 } + val legacy = LegacyPlayerInfoReference().apply { parse(buffer) } + assertEquals(bytes.size, buffer.readcount) + assertEquals(0, legacy.deltaFlags and PS_WEAPONINDEX) + assertEquals(current.gunframe, legacy.currentState.gunframe) + } + } + + @Test + fun `weapon changes zero resets and full snapshots retain explicit field presence`() { + for (oldIndex in listOf(0, 1, 127, 128, 255)) for (newIndex in listOf(0, 1, 127, 128, 255)) { + val previous = player_state_t().apply { gunindex = oldIndex } + val current = player_state_t().apply { gunindex = newIndex } + val message = PlayerInfoMessage.fromGame(previous, current) + assertEquals(oldIndex != newIndex, message.deltaFlags and PS_WEAPONINDEX != 0) + assertEquals(if (oldIndex == newIndex) null else newIndex, message.weaponIndex) + val decoded = read(write(message)) + val reconstructed = previous.getClone().also(decoded::applyTo) + assertEquals(newIndex, reconstructed.gunindex) + } + val full = PlayerInfoMessage.fromGame(player_state_t(), player_state_t().apply { gunindex = 7 }) + assertEquals(7, read(write(full)).weaponIndex) + val zero = PlayerInfoMessage.fromGame(player_state_t(), player_state_t()) + assertNull(read(write(zero)).weaponIndex) + assertEquals(0, player_state_t().also(zero::applyTo).gunindex) } @Test From a98fbb26775deac9faf99593b5713bda3f9d3928 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 15:15:42 +0100 Subject: [PATCH 53/57] docs: network doc update --- info/Networking.md | 67 +++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/info/Networking.md b/info/Networking.md index 81107e89..ca50574e 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -22,50 +22,61 @@ ## Transport ownership - Client and server each own a separate transport. -- Each transport manages its own UDP socket and one endpoint of the local link. +- Each transport manages an optional UDP socket (optional, for multiplayer) and one endpoint of the local link (singleplayer and local hosting). - Stopping or closing one side preserves messages already queued for the other side. - Map changes preserve reconnect traffic; clearing both local queues requires an explicit session-level decision. ## NetworkPacket -Every frame both client and servers are sending a jake2.qcommon.network.messages.NetworkPacket to each other. -Each such packet contains a header and several (Client|Server)Messages. +`NetworkPacket` adapts a received packet for the engine. Outgoing packets are built as bytes; +sending depends on connection state, rate limiting, and keepalive timing, rather than occurring on every frame on both sides. +Sequenced packets contain a header and zero or more `ClientMessage` or `ServerMessage` values. +Connectionless packets contain text instead. -`MessageBodyCodec` decodes a sequenced payload with bounded reads before controllers -receive any messages. It returns either the complete collection or a typed rejection, -so a valid prefix of a structurally malformed packet cannot trigger application commands. Lengths, -opcodes, directions, and table indices are checked without global teardown from codecs. -The server drops the offending connection; the client returns to its console on a bad -server payload. Movement checksum validity keeps its existing `MoveMessage.valid`/controller policy. -Legacy file/demo readers keep their existing `sizebuf_t` behavior. +`MessageBodyCodec` validates the whole payload before dispatch, returning all messages or a typed rejection. +Malformed payloads 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. ## "Connectionless" (Out of band) packet Such packets/messages are sent ad-hoc and don't require a running map instance, therefore could be sent by not connected clients. They are separate datagrams rather than messages inside a sequenced game-channel packet. `PacketCodec` identifies them by their leading -1 sequence marker; the engine adapter can expose both kinds as `NetworkPacket` values. -Mostly such packets contain a single string (though there are some cases when 2 strings are sent - `info` & `print`) +The payload is text. For server `info` and `print` replies, a newline separates the +command line from the remaining reply text; these are not two separately encoded strings. see `jake2.qcommon.network.messages.ConnectionlessCommand` ## Reliability -Since quake2 utilizes only UDP protocol, reliability should be handled by the quake2 engine itself. -Each frame server tries to send a reliable message first, then if there is room append an unreliable buffer and send. +Remote communication uses UDP; in-process communication uses `LoopbackLink`. +Both use the Quake II packet protocol and its reliability machinery. +When reliable data is ready to send or retransmit, it goes first, followed by the +unreliable batch if there is room. A packet can also contain only its header. -The implementation is `jake2.qcommon.network.netchan_t.transmit`. Sequence and -acknowledgement fields track reliable delivery; a dropped reliable payload is -resent, and unreliable data is included only when the packet has room. +The entry point is `ConnectionChannel.transmit`; protocol state is managed by +`ReliableChannel`. Sequence and acknowledgement fields track reliable delivery; +a dropped reliable payload is resent when acknowledgement state indicates a retry is needed. ## Delta compression The bulk of the network traffic between server & client constitute entity updates and move messages. -In order to minimize the amount of information sent over the network quake2 protocol uses delta compression - an attempt to send only the difference from the previously sent command. +To reduce traffic, the Quake II protocol uses delta compression: sending selected fields relative to a reference state, rather than always sending the complete state. The following messages use delta compression: 1. `PlayerInfoMessage` (from server) 2. `PacketEntitiesMessage` (from server) 3. `MoveMessage` (from client) -The approach is the same for all of them - first identify what fields have changed (`deltaFlags`, like a bitmask), then sent only those fields. +The reference depends on the message: + +- Player state uses a client-acknowledged frame when available, otherwise a zero state. +- Entity updates use the acknowledged frame for existing entities and baselines for newly visible entities. +- Movement sends three commands as a delta chain: the oldest against a zero command, then each later command against the preceding one. + +Field masks identify what is transmitted. Some fields are sent even when unchanged; +for example, movement commands always include their duration and light level. +Player-state updates send the weapon index only when it differs from the reference state, +including changes to zero. ## Server Messages see package `jake2.qcommon.network.messages.server` @@ -86,13 +97,15 @@ sequenceDiagram participant server Note over client: User types connect hostname - client->>server: getchallenge - server->>client: challenge + opt Remote UDP connection + client->>server: getchallenge [connectionless] + server->>client: challenge [connectionless] + end client->>server: connect [connectionless] Note over server: client.state = connected server->>client: client_connect [connectionless] - client->>server: StringCmdMessage new Note over client: state = connected + client->>server: StringCmdMessage new server->>client: ServerDataMessage loop until all config strings are transmitted @@ -107,15 +120,19 @@ sequenceDiagram server->>client: SpawnBaselineMessage end Note over client: precache resources - loop until everything is downloaded - client->>server: StringCmdMessage download - server->>client: DownloadMessage + loop for each missing file + client->>server: StringCmdMessage download path [offset] + server->>client: DownloadMessage first chunk + loop remaining chunks of this file + client->>server: StringCmdMessage nextdl + server->>client: DownloadMessage next chunk + end end client->>server: StringCmdMessage begin Note over server: client.state = spawned - loop Every frame + loop During gameplay, when updates are sent server->>client: FrameHeaderMessage server->>client: PlayerInfoMessage server->>client: PacketEntitiesMessage From ab7d09aa7db6eb3ddd533bd263f14a42582484e4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 15:16:28 +0100 Subject: [PATCH 54/57] Clarify sequenced and connectionless packet documentation --- info/Networking.md | 51 ++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/info/Networking.md b/info/Networking.md index ca50574e..f1033f73 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -28,24 +28,39 @@ ## NetworkPacket -`NetworkPacket` adapts a received packet for the engine. Outgoing packets are built as bytes; -sending depends on connection state, rate limiting, and keepalive timing, rather than occurring on every frame on both sides. -Sequenced packets contain a header and zero or more `ClientMessage` or `ServerMessage` values. -Connectionless packets contain text instead. - -`MessageBodyCodec` validates the whole payload before dispatch, returning all messages or a typed rejection. -Malformed payloads 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. - -## "Connectionless" (Out of band) packet -Such packets/messages are sent ad-hoc and don't require a running map instance, therefore could be sent by not connected clients. -They are separate datagrams rather than messages inside a sequenced game-channel packet. -`PacketCodec` identifies them by their leading -1 sequence marker; the engine adapter can expose both kinds as `NetworkPacket` values. -The payload is text. For server `info` and `print` replies, a newline separates the -command line from the remaining reply text; these are not two separately encoded strings. - -see `jake2.qcommon.network.messages.ConnectionlessCommand` +`NetworkPacket` adapts a received datagram for the engine. `PacketCodec` identifies +two packet types, represented by `PacketEnvelope.Sequenced` and +`PacketEnvelope.Connectionless`. + +### Sequenced packets + +These carry communication over an established game connection: + +- **Header:** sequence and acknowledgement fields, plus a qport on client-to-server packets. +- **Body:** zero or more typed `ClientMessage` or `ServerMessage` values, such as movement, player state, or entity updates. +- **Handling:** peer routing and `ReliableChannel` acceptance, followed by message-body decoding and dispatch. + +`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. + +### Connectionless (out-of-band) packets + +These are independent datagrams that can be sent by connected or unconnected peers, +without requiring a running map: + +- **Marker:** a leading sequence value of -1 identifies a connectionless packet. +- **Payload:** text commands and replies, such as `getchallenge`, `connect`, `ping`, and `print`, described by `ConnectionlessCommand`. +- **Handling:** connectionless command or handshake handlers, bypassing `ReliableChannel` and `MessageBodyCodec`. Handshake retries are managed by `ClientHandshake`. + +For server `info` and `print` replies, a newline separates the command line from the +remaining reply text; these are not two separately encoded strings. + +Outgoing packets are built as bytes. Sending depends on connection state, rate +limiting, and keepalive or handshake timing, rather than occurring on every frame +on both sides. ## Reliability Remote communication uses UDP; in-process communication uses `LoopbackLink`. From 160191e0f85ba1f6dd91724648c9f851e6378ef2 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 15:21:39 +0100 Subject: [PATCH 55/57] Label sequenced and connectionless traffic in connection diagram --- info/Networking.md | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/info/Networking.md b/info/Networking.md index f1033f73..45edf1df 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -106,6 +106,9 @@ see jake2.server.ClientStates The following diagram illustrates client/server communication when client connects to server. Starting from typing connect 'someaddress' and until client becomes connected and receives the updates from the server. +`[s]` marks a message carried in a sequenced packet; `[c]` marks a connectionless packet. +Sequence numbers belong to packets, and several `[s]` messages may share one packet. + ```mermaid sequenceDiagram participant client @@ -113,44 +116,44 @@ sequenceDiagram Note over client: User types connect hostname opt Remote UDP connection - client->>server: getchallenge [connectionless] - server->>client: challenge [connectionless] + client->>server: [c] getchallenge + server->>client: [c] challenge end - client->>server: connect [connectionless] + client->>server: [c] connect Note over server: client.state = connected - server->>client: client_connect [connectionless] + server->>client: [c] client_connect Note over client: state = connected - client->>server: StringCmdMessage new - server->>client: ServerDataMessage + client->>server: [s] StringCmdMessage new + server->>client: [s] ServerDataMessage loop until all config strings are transmitted - server->>client: StuffTextMessage cmd configstrings - client->>server: StringCmdMessage configstrings - server->>client: ConfigStringMessage + server->>client: [s] StuffTextMessage cmd configstrings + client->>server: [s] StringCmdMessage configstrings + server->>client: [s] ConfigStringMessage end loop until all baselines are transmitted - server->>client: StuffTextMessage cmd baselines - client->>server: StringCmdMessage baselines - server->>client: SpawnBaselineMessage + server->>client: [s] StuffTextMessage cmd baselines + client->>server: [s] StringCmdMessage baselines + server->>client: [s] SpawnBaselineMessage end Note over client: precache resources loop for each missing file - client->>server: StringCmdMessage download path [offset] - server->>client: DownloadMessage first chunk + client->>server: [s] StringCmdMessage download path [offset] + server->>client: [s] DownloadMessage first chunk loop remaining chunks of this file - client->>server: StringCmdMessage nextdl - server->>client: DownloadMessage next chunk + client->>server: [s] StringCmdMessage nextdl + server->>client: [s] DownloadMessage next chunk end end - client->>server: StringCmdMessage begin + client->>server: [s] StringCmdMessage begin Note over server: client.state = spawned loop During gameplay, when updates are sent - server->>client: FrameHeaderMessage - server->>client: PlayerInfoMessage - server->>client: PacketEntitiesMessage - client->>server: MoveMessage + server->>client: [s] FrameHeaderMessage + server->>client: [s] PlayerInfoMessage + server->>client: [s] PacketEntitiesMessage + client->>server: [s] MoveMessage end ``` From a60578a32628c170594ef6b771a4f09d1a2a944c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 16:02:20 +0100 Subject: [PATCH 56/57] docs: network doc update --- info/Networking.md | 4 ++-- .../main/kotlin/jake2/qcommon/network/ConnectionChannel.kt | 2 +- .../qcommon/network/messages/client/ClientMessageCodec.kt | 2 +- .../network/messages/server/PacketEntitiesMessage.kt | 2 +- .../qcommon/network/messages/server/PlayerStateCodec.kt | 3 ++- .../kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt | 2 +- .../jake2/qcommon/network/protocol/MessageDecodeResult.kt | 2 +- .../kotlin/jake2/qcommon/network/protocol/MessageReader.kt | 6 +++--- .../kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt | 2 +- 9 files changed, 13 insertions(+), 12 deletions(-) diff --git a/info/Networking.md b/info/Networking.md index 45edf1df..6e2faf60 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -34,7 +34,7 @@ two packet types, represented by `PacketEnvelope.Sequenced` and ### Sequenced packets -These carry communication over an established game connection: +These carry communication over an established game connection (during playing the map): - **Header:** sequence and acknowledgement fields, plus a qport on client-to-server packets. - **Body:** zero or more typed `ClientMessage` or `ServerMessage` values, such as movement, player state, or entity updates. @@ -46,7 +46,7 @@ or the receiving client to return to its console. A failed movement checksum mak the server ignore the rest of the packet. File/demo readers retain their existing `sizebuf_t` behavior. -### Connectionless (out-of-band) packets +### Non Sequenced / Connectionless / out-of-band packets These are independent datagrams that can be sent by connected or unconnected peers, without requiring a running map: diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt index 8a6fc5e0..38487364 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt @@ -55,7 +55,7 @@ class ConnectionChannel @JvmOverloads constructor( var qport = 0 private set - // Read protocol state directly; these values are never mutable compatibility snapshots. + // Read protocol state directly from the owned reliable channel. val incomingSequence: Int get() = channel.incomingSequence val incomingAcknowledged: Int get() = channel.incomingAcknowledged val outgoingSequence: Int get() = channel.outgoingSequence diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt index 78c1d15b..b00f1fb8 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt @@ -36,7 +36,7 @@ object ClientMessageCodec { else -> error("Unsupported client message ${message.javaClass.name}") } - /** Construct complete values from bounded reads; preserve cursor updates for direct legacy factory callers. */ + /** Construct complete values from bounded reads and update the supplied buffer's cursor. */ @JvmStatic fun read(buffer: sizebuf_t, incomingSequence: Int): ClientMessage { if (buffer !is MessageReader) { diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt index 4ed3d5a7..e1f6748b 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesMessage.kt @@ -23,7 +23,7 @@ sealed interface EntityUpdate { override val number: Int get() = delta.state.index init { require(number != 0 && delta.flags and U_REMOVE == 0) } } - /** Removal carries only the header (former entity bits), even if other field bits are set. */ + /** Removal carries only the entity header, even if payload field bits are set. */ data class Removal(val flags: Int, override val number: Int) : EntityUpdate { init { require(number in 1 until MAX_EDICTS && flags and U_REMOVE != 0) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt index 240a6ccc..b65fc74d 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt @@ -20,6 +20,7 @@ internal object PlayerStateCodec { viewAngles = if (different(current.viewangles, previous.viewangles)) angleShorts(current.viewangles) else null, kickAngles = if (different(current.kick_angles, previous.kick_angles)) quarterBytes(current.kick_angles) else null, // Compare against the acknowledged reference, including resets to zero. + // Different from vanilla server encoding behavior, but should be compatible with most clients weaponIndex = if (current.gunindex != previous.gunindex) current.gunindex and 255 else null, // Gun offsets/angles are sent only when gunframe changes, preserving protocol-34 behavior. weaponFrame = if (current.gunframe != previous.gunframe) PlayerWeaponFrame(current.gunframe and 255, quarterBytes(current.gunoffset), quarterBytes(current.gunangles)) else null, @@ -30,7 +31,7 @@ internal object PlayerStateCodec { .associateWith { current.stats[it].toInt() }), ) - // Compare before quantization, just as the legacy writer did (including sub-unit changes). + // Changes smaller than one wire unit still set the field mask. private fun different(a: FloatArray, b: FloatArray, count: Int = 3): Boolean = (0 until count).any { a[it] != b[it] } private fun different(a: ShortArray, b: ShortArray): Boolean = (0 until 3).any { a[it] != b[it] } private fun shorts(v: ShortArray) = PlayerDeltaVector(v[0].toInt(), v[1].toInt(), v[2].toInt()) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt index 62b8dc0f..a702af8d 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/DirectionCodec.kt @@ -3,7 +3,7 @@ package jake2.qcommon.network.protocol import jake2.qcommon.Globals import jake2.qcommon.math.Vector3f -/** Immutable snapshot of the protocol's fixed normal table and its legacy nearest-dot-product selection rule. */ +/** Immutable normal table; select the greatest positive dot product, defaulting to index zero. */ object DirectionCodec { private val normals = Globals.bytedirs.map(::Vector3f) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt index 95535e66..a3603d6b 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageDecodeResult.kt @@ -8,5 +8,5 @@ sealed interface MessageDecodeResult { enum class MessageRejection { TRUNCATED, UNKNOWN_OPCODE, INVALID_LENGTH, INVALID_VALUE, WRONG_DIRECTION, NOT_SEQUENCED } -/** Internal unwinding for legacy message parsers; the body codec converts it into a rejection. */ +/** Abort decoding; [MessageBodyCodec] converts this exception into a typed rejection. */ class MessageReadException(val reason: MessageRejection, val offset: Int, detail: String) : RuntimeException(detail, null, false, false) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt index 78d9fbb4..639e0581 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -6,7 +6,7 @@ import jake2.qcommon.math.Vector3f import jake2.qcommon.sizebuf_t import jake2.qcommon.network.transport.PacketBytes -/** Owned packet bytes with bounded reads; legacy file/demo buffer readers retain their sentinel API. */ +/** Owned packet bytes with bounded reads; [sizebuf_t] file/demo readers retain their sentinel API. */ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { init { require(offset in 0..bytes.size) @@ -16,13 +16,13 @@ class MessageReader(bytes: PacketBytes, offset: Int) : sizebuf_t(bytes.size) { } companion object { - /** Bridge direct legacy factory calls into bounded reads and let the factory copy back its cursor. */ + /** Adapt a buffer at its current cursor; callers copy the reader cursor back after decoding. */ @JvmStatic fun fromBuffer(buffer: sizebuf_t): MessageReader { requireRemaining(buffer, 0) return MessageReader(PacketBytes.copyOf(buffer.data, buffer.cursize), buffer.readcount) } - /** Validate a wire length before allocation or copying, including when a legacy parser is called directly. */ + /** Validate the byte count and buffer bounds before reading, allocating, or copying. */ @JvmStatic fun requireRemaining(buffer: sizebuf_t, length: Int) { if (length < 0) throw MessageReadException(MessageRejection.INVALID_LENGTH, buffer.readcount, "Negative length $length") if (buffer.readcount < 0 || buffer.cursize > (buffer.data?.size ?: 0) || length > buffer.cursize - buffer.readcount) diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt index e484d285..de0021c2 100644 --- a/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -79,7 +79,7 @@ class PacketNetwork @JvmOverloads constructor( } } - /** Compatibility nullable view; explicit transport/codec outcomes remain below this adapter. */ + /** Return a decoded packet, or null when the receive yields no packet, invalid data, or an I/O failure. */ fun receive(): NetworkPacket? { return decode(transport.receive()) } From e820fed8c3634d1c8d1fdeb2b260db5ab1914807 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Thu, 29 Oct 2026 16:05:45 +0100 Subject: [PATCH 57/57] Bump version to 1.2.6 for networking rework --- ChangeLog | 4 ++++ build.gradle | 2 +- .../kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt | 2 +- .../main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ChangeLog b/ChangeLog index 112308bd..e09fda31 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,7 @@ +1.2.6 + - reworked networking with owned client/server transports, immutable Kotlin messages and addresses, and injected settings + - preserved Quake II protocol 34 compatibility and reduced player updates by omitting unchanged weapon model indices + 1.2.2 - added cake-fullgame desktop distribution with Singleplayer and Host Game menus - embedded server and game modules for local singleplayer and multiplayer hosting diff --git a/build.gradle b/build.gradle index 57fc8683..3da40ed6 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ subprojects { apply plugin: 'jacoco' group = 'org.demoth' - version = '1.2.5' + version = '1.2.6' ext.appName = 'cake' kotlin { diff --git a/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt index a017c64b..ce980770 100644 --- a/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt +++ b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt @@ -29,7 +29,7 @@ private fun createApplication(args: Array): Lwjgl3Application { } private fun createConfiguration() = Lwjgl3ApplicationConfiguration().apply { - setTitle("Cake Viewer 1.2.5") + setTitle("Cake Viewer 1.2.6") setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) useVsync(true) //// Limits FPS to the refresh rate of the currently active monitor. diff --git a/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt b/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt index d363d2b4..dc706c95 100644 --- a/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt +++ b/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt @@ -39,7 +39,7 @@ object CakeDesktopLauncher { val displayMode = modes.firstOrNull { it.toCakeMode() == selected && it.bitsPerPixel == desktop.bitsPerPixel } ?: modes.firstOrNull { it.toCakeMode() == selected } ?: desktop return Lwjgl3ApplicationConfiguration().apply { - setTitle("Cake Engine v1.2.5") + setTitle("Cake Engine v1.2.6") setInitialVisible(!videoMode.hidden) setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) setBackBufferConfig(