diff --git a/ChangeLog b/ChangeLog index 112308bdb..e09fda316 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/README.md b/README.md index 37f60debc..a7b15197f 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/build.gradle b/build.gradle index 57fc8683f..3da40ed6f 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/README.md b/cake/README.md index dd0d32223..e169aa06f 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 @@ -115,14 +120,44 @@ 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. -Console equivalents: +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. -- `host_start [singleplayer|deathmatch|coop] [skill] [maxclients]` -- `host_stop` — disconnect and stop hosting. -- `map ` / `gamemap ` — change maps while hosting. +Console equivalents: -Saves are stored in `~/.cake//save/`. Console `save ` and -`load ` currently require an active game. +- `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. + +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. + +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. +Each screen shows a scrolling checkpoint list alongside preview space and save +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, +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 diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index 8d6fc6007..e4abf52fe 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/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 1e5557d36..a275e0fe0 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,38 +1,92 @@ 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 import jake2.qcommon.exec.find import jake2.qcommon.longjmpException -import jake2.qcommon.network.NET +import jake2.qcommon.network.transport.LoopbackLink +import jake2.qcommon.network.runtime.NetworkTransport +import jake2.server.TransportServerNetwork +import org.demoth.cake.network.TransportClientNetwork 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 jake2.server.save.deleteSaveSlot 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 -/** 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"), + private val localLink: LoopbackLink = LoopbackLink(), ) : CakeHost { + 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 (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() + } + internal var server: ServerHost? = null private set 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() + val rejection = { + when { + disposed -> "Host has been disposed" + activeProfile == null -> "Select a game profile before hosting." + else -> this.startupRejection() + } + } + server = ServerHost(TransportServerNetwork(serverTransport), rejection) + } + + 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 +103,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,11 +128,18 @@ 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) { + if (request.mode != HostMode.SINGLEPLAYER && udpEndpoint(Defines.NS_SERVER) == null) { stop("Server port unavailable") return HostStartResult.Rejected("Could not open the server UDP port") } @@ -107,10 +169,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 +183,30 @@ 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) clearLocalTraffic() + } + } + + 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) clearLocalTraffic() + try { clientNetwork.close() } finally { localLink.close() } + previousWriteRoot?.let(EngineWriteRoot::setRoot) + previousWriteRoot = null + activeProfile = null } } @@ -153,8 +219,136 @@ class EmbeddedCakeHost( cvars.find("paused").forceSet(paused && singlePlayer) } - private fun configureFilesystem(request: HostRequest) { - val profile = request.profile.normalized() + 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, previewPng: ByteArray?): 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 == "autosave") { + 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.") + } + 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 { + 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) + } + + 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." + 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).takeIf { it == 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, + entry.mapTitle) + } + + 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) + 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 +360,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 +374,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/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 000000000..620ea6b6e --- /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/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 8cb7d4d7f..31528f30a 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(); @@ -55,7 +56,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 +66,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 +92,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); @@ -110,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; @@ -129,7 +130,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 +168,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); @@ -176,10 +177,10 @@ public void clientSnapshotsKeepIndependentGameHistory() { var initial = List.copyOf(first.buildClientFrameMessages(firstClient)); Assertions.assertEquals(3, initial.size()); - Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).lastFrame); - EntityUpdate initialUpdate = ((PacketEntitiesMessage) initial.get(2)).updates.get(0); - Assertions.assertTrue(initialUpdate.force); - Assertions.assertEquals(11, initialUpdate.newState.modelindex); + Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).getLastFrame()); + 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. @@ -192,28 +193,64 @@ 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); - var updates = ((PacketEntitiesMessage) messages.get(2)).updates; + Assertions.assertEquals(2, header.getFrameNumber()); + Assertions.assertEquals(1, header.getLastFrame()); + 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)).lastFrame); - Assertions.assertTrue(((PacketEntitiesMessage) stale.get(2)).updates.get(0).force); + Assertions.assertEquals(-1, ((FrameHeaderMessage) stale.get(0)).getLastFrame()); + 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) { @@ -221,7 +258,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; @@ -229,7 +266,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); @@ -250,21 +287,22 @@ 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 << 3) | 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, 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.assertArrayEquals(new float[]{1, 2, 3}, reliable.origin); + 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; } @@ -272,18 +310,18 @@ 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"); 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()); } @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 +345,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 +384,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(); @@ -367,12 +405,12 @@ 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.getQueuedReliable().iterator().next()).getLevelString()); Assertions.assertEquals("second game", - ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).levelString); - 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; @@ -380,7 +418,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 +430,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 +460,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/CakeFreshLoadGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeFreshLoadGlTest.kt new file mode 100644 index 000000000..28cb960ed --- /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/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index ca03815ef..32b7b85dc 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 @@ -9,13 +10,17 @@ 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.network.Netchan -import jake2.qcommon.Defines.NS_SERVER -import jake2.qcommon.network.netadr_t -import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.qcommon.network.NetworkSettings +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 import org.demoth.cake.CakeCvars import org.demoth.cake.CakeStartupBootstrap import org.demoth.cake.fullgame.EmbeddedCakeHost @@ -27,6 +32,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") @@ -38,13 +44,18 @@ 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() MainCommon.registerCvars(false) CakeCvars.registerAll() - Netchan.Netchan_Init() + NetworkSettings.registerCvars() val cvars = Cvar.getInstance() cvars.find("port").forceSet(0) cvars.find("clientport").forceSet(0) @@ -57,10 +68,23 @@ 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() + 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() @@ -72,7 +96,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) @@ -90,29 +119,53 @@ 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, "") + staleLocalReply() + 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 autosave") cake!!.render() assertEquals("CONNECTING", clientState(cake!!)) - Cbuf.AddText("host_start missing") + assertEquals(HostState.STOPPED, host.state) + assertArrayEquals(before, Files.readAllBytes(current)) + Cbuf.AddText("disconnect; map missing") cake!!.render() assertEquals(HostState.STOPPED, host.state) } 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("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") + } + 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() } } @@ -135,8 +188,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 906e65cf2..87fa089a7 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 @@ -36,7 +35,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 { @@ -49,7 +48,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") @@ -59,7 +58,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) } } @@ -84,7 +83,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 { host.udpEndpoint(it) == null }) origin = playerOrigin(host) next("+forward") } @@ -97,32 +96,32 @@ 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 { host.udpEndpoint(it) == null }) next("map base1") } 3 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base1") { 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 -> { 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 { host.udpEndpoint(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]!! - assertNull(NET.ip_sockets[Defines.NS_CLIENT]) - clientPeer = launchPeer("client", basedir, clientReady, serverSocket!!.localPort) + 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 +130,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, host.udpEndpoint(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) clientPeer = launchPeer("client", basedir, clientReady, - NET.ip_sockets[Defines.NS_SERVER]!!.localPort) + host.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port) peers.add(clientPeer) next() } @@ -157,19 +156,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(host.udpEndpoint(Defines.NS_SERVER)) + assertNotNull(host.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 { host.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 { host.udpEndpoint(it) == null }) Gdx.app.exit() } } @@ -195,7 +194,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/CakeNewGameGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeNewGameGlTest.kt new file mode 100644 index 000000000..6d6854fc4 --- /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.NEW_GAME, 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) + } + } +} 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 0dc259aff..b129ee51a 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")) @@ -46,7 +48,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() + 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 @@ -60,7 +67,6 @@ class CakeProfileSaveGlTest { try { cake = Cake(startup, host = host) cake.create() - Cbuf.AddText("host_start profilemap") } catch (error: Throwable) { fail(error) } } @@ -71,11 +77,17 @@ 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 { 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() { @@ -87,6 +99,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 @@ -101,37 +115,51 @@ 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) - next("host_stop") + // 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 -> { assertEquals("DISCONNECTED", state()) Cmd.ExecuteString("cake_profile_set second \"$content\"") - next("host_start base2") + assertEquals("second", catalog().profileId) + assertTrue(catalog().entries.isEmpty()) + 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") + 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("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. + assertEquals("first", catalog().profileId) + loadShared() } - 8 -> if (active("base2")) next("load shared") - 9 -> if (active("profilemap")) { + 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() @@ -154,7 +182,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/CakeSaveMenusGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt new file mode 100644 index 000000000..9ad8abd7c --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSaveMenusGlTest.kt @@ -0,0 +1,279 @@ +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 +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.writeScreenshot +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 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() } + + 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!! + verifyPreview() + originalSave = Files.readString(checkpoint()) + player().health = 41 + click("save-overwrite") + next() + } + 4 -> { + assertEquals(slot, state().pendingOverwriteSlot) + click("cancel-save-confirmation") + 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())) + verifyPreview() + 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) + assertNull(stage().root.findActor("save-preview-image").drawable) + 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 }) + verifyPreview() + click("load-game") + next() + } + 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())) + verifyPreview() + writeScreenshot(Gdx.files.local("build/reports/save-menu-layout.png")) + 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/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt index e96ecdb98..eb1025d2f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeSessionPeer.kt @@ -9,8 +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.NET -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 @@ -31,12 +30,13 @@ 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 { + 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, host.udpEndpoint(Defines.NS_SERVER)!!.localAddress.port.toString()) var previous = System.nanoTime() while (System.nanoTime() < deadline) { Thread.sleep(5) @@ -44,7 +44,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 77f76ee49..3e9a14df7 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -6,8 +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.NET -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 @@ -39,21 +38,100 @@ 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() host = EmbeddedCakeHost(temp) + host.initialize() } @AfterEach fun cleanup() { - host.stop("test cleanup") - NET.Config(false) + host.dispose() 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(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(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("autosave")) + 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(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(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())) @@ -78,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" @@ -87,12 +165,12 @@ 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 - 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)) @@ -108,7 +186,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 { host.udpEndpoint(it) == null }) } @Test @@ -117,7 +195,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,15 +227,15 @@ 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) 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(NET.ip_sockets.all { it == null }) + assertTrue(Files.exists(temp.resolve("session/save/autosave/game.ssv.json"))) + 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) @@ -165,10 +243,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) } @@ -186,20 +264,23 @@ 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) } @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 { @@ -223,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.ip_sockets[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(NET.ip_sockets.all { it == null }) + assertTrue(listOf(Defines.NS_CLIENT, Defines.NS_SERVER).all { host.udpEndpoint(it) == null }) } } @@ -240,25 +321,24 @@ 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 }) - assertNull(Cmd.FindCommand("map")) + 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.ip_sockets[Defines.NS_SERVER]) - assertNull(NET.ip_sockets[Defines.NS_CLIENT]) + assertNotNull(host.udpEndpoint(Defines.NS_SERVER)) + assertNull(host.udpEndpoint(Defines.NS_CLIENT)) } @Test 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 +361,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 +374,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/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 000000000..d2dba8c9b --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeSavesTest.kt @@ -0,0 +1,276 @@ +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.NetworkSettings +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) + NetworkSettings.registerCvars() + 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() + 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("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("autosave")) + } + + @Test + fun `catalog and load follow selected profile while idle and use profile rather than shared root`() { + start() + ServerSaveFiles(root.resolve("first")).copy("autosave", "console slot") + host.stop("browse") + assertEquals(setOf("autosave", "console slot"), entries().map { it.slot }.toSet()) + EngineWriteRoot.setRoot(root.resolve("unrelated")) + 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")) + 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", "autosave", "../outside")) { + assertInstanceOf(HostSaveResult.Rejected::class.java, host.saveGame(slot, true)) + } + 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)) + 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("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 == "autosave" }.loadRejectionReason) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.loadGame("corrupt")) + // Browsing reads metadata only; authoritative preflight still rejects a corrupt game envelope. + 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"]) + } + + @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/current/server_mapcmd.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 `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() + 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("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")) + 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("autosave")) + remote = false + EngineWriteRoot.setRoot(root.resolve("other")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) + assertTrue(Files.exists(root.resolve("first/save/autosave/game.ssv.json"))) + EngineWriteRoot.setRoot(root.resolve("first")) + } + + @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/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 81dcf2c3f..50f3954b0 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -4,8 +4,8 @@ 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.netadr_t +import jake2.qcommon.network.NetworkSettings +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.network.messages.server.StuffTextMessage import jake2.qcommon.vfs.* import org.junit.jupiter.api.* @@ -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 @@ -30,11 +30,12 @@ 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) - 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 @@ -62,12 +64,41 @@ 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(netchan.remoteAddress, index) } 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/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() + 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("autosave").mapCommand()) + } + } + @Test fun `live timelimit ends only its owning Game`() { Cvar.getInstance().find("deathmatch").forceSet(true) @@ -106,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.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) @@ -158,13 +189,13 @@ 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.remote_address = null // Fail the first final notification. + a.netchan.queueReliable(StuffTextMessage.command("stale")) + a.unreliable.add(StuffTextMessage.command("stale")) + 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(NET.configureServer(true)) - val socket = NET.ip_sockets[Defines.NS_SERVER] + assertTrue(network.server.configure(true)) + val socket = network.serverTransport.udpEndpoint val peerChannel = b.netchan host.stopGame(first, "Game failed\n") @@ -174,11 +205,11 @@ 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"]) - assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + assertSame(socket, network.serverTransport.udpEndpoint) assertSame(peerChannel, b.netchan) assertEquals(ClientStates.CS_CONNECTED, b.state) host.update(100) @@ -267,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 } @@ -337,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.queuedReliable.contains(StuffTextMessage.command("reconnect"))) } @ParameterizedTest @@ -349,15 +380,15 @@ 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/save0/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") @@ -366,13 +397,13 @@ class GameContinuationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[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") 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/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 1b3f2fed6..2ba2eb3eb 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.* @@ -7,6 +9,9 @@ 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.* import org.junit.jupiter.api.Assertions.* @@ -14,9 +19,32 @@ 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 + private lateinit var network: HostNetworkFixture private lateinit var host: ServerHost private lateinit var first: GameImportsImpl private lateinit var second: GameImportsImpl @@ -29,12 +57,12 @@ class HostClientAccountingTest { Globals.server_state = ServerStates.SS_DEAD cvars.clear() MainCommon.registerCvars(false) - Netchan.Netchan_Init() - drainPackets() + NetworkSettings.registerCvars() 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) @@ -62,13 +90,14 @@ class HostClientAccountingTest { lastconnect = 100 rate = 100000 state = ClientStates.CS_CONNECTED - netchan.setup(Defines.NS_SERVER, netadr_t(), index) + netchan.setup(netchan.remoteAddress, index) } @AfterEach fun cleanup() { host.clients.forEach { it.state = ClientStates.CS_FREE } host.close() + network.close() drainPackets() EngineWriteRoot.setRoot(previousRoot) Globals.server_state = previousState @@ -77,7 +106,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 (network.receiveLocal(side) != null) { } } } @@ -137,7 +166,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 { @@ -163,28 +192,44 @@ 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( - jake2.qcommon.network.messages.server.StuffTextMessage("x".repeat(Defines.MAX_MSGLEN))) - peer.netchan.reliablePending.add(jake2.qcommon.network.messages.server.StuffTextMessage("peer")) - val previousSequence = peer.netchan.outgoing_sequence + client.netchan.queueReliable( + jake2.qcommon.network.messages.server.StuffTextMessage.command("x".repeat(Defines.MAX_MSGLEN))) + 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]) } + @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.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.queuedReliable.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) 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) @@ -234,7 +279,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)) @@ -261,7 +306,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() @@ -272,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.remote_address.type = NetAddrType.NA_IP + 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) { @@ -302,43 +347,33 @@ 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(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 -> - 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(NET.configureServer(true)) + assertTrue(network.server.configure(true)) host.handleConnectionlessPacket(address, "getchallenge") assertEquals(issued, reply(), "Repeated requests retain the existing challenge") - NET.configureServer(false) - host.handleConnectionlessPacket(netadr_t().apply { - type = NetAddrType.NA_IP - ip = byteArrayOf(198.toByte(), 51, 100, 1) - }, "getchallenge") - assertTrue(NET.configureServer(true)) + network.server.configure(false) + 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") assertTrue(reply().contains("No challenge for address")) } finally { Globals.curtime = previousTime - NET.configureServer(false) + network.server.configure(false) } } } @@ -350,11 +385,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.ip_sockets[Defines.NS_SERVER]!!.localPort)) + 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,33 +410,40 @@ class HostClientAccountingTest { assertEquals(first.mapName, Info.Info_ValueForKey(info, "mapname")) assertFalse(text.contains("another-game-map")) } finally { - NET.configureServer(false) + network.server.configure(false) } } } @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 = NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)!!.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 = NetworkAddress.Udp(IpAddress.fromBytes(loopback.address), 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/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 000000000..31a815bae --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostNetworkFixture.kt @@ -0,0 +1,54 @@ +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.* +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, CvarNetworkSettings(Cvar.getInstance())) + + /** 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() + } + + 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 b7d43a4fa..a9a9de5b8 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() @@ -28,18 +28,19 @@ 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"))) 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 @@ -54,12 +55,37 @@ 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/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() } } + 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) { 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) @@ -72,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.ip_sockets[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]) @@ -82,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) @@ -289,7 +317,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) } @@ -319,10 +347,10 @@ 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(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") @@ -350,7 +378,7 @@ class HostSessionPreparationTest { assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[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")) @@ -375,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.ip_sockets[Defines.NS_SERVER]) + assertNull(network.serverTransport.udpEndpoint) } cvars.find("port").forceSet(0) assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) @@ -425,19 +453,17 @@ 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") - 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()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) - assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(network.serverTransport.udpEndpoint) 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")) } @@ -467,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.ip_sockets[Defines.NS_CLIENT] - val serverSocket = NET.ip_sockets[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()) + client.netchan.setup(client.netchan.remoteAddress, 0) + 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 } @@ -484,20 +510,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(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.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(network.receiveLocal(Defines.NS_SERVER)) + assertNotNull(network.receiveLocal(Defines.NS_CLIENT)) + assertNotNull(network.receiveLocal(Defines.NS_CLIENT)) host.stop("again", false) - assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + assertNull(network.receiveLocal(Defines.NS_CLIENT)) jake2.qcommon.exec.Cbuf.InsertFromDefer() jake2.qcommon.exec.Cbuf.Execute() assertFalse(deferredRan) @@ -506,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() } } @@ -519,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.ip_sockets[Defines.NS_SERVER]) + assertNotNull(network.serverTransport.udpEndpoint) } @Test @@ -527,14 +553,15 @@ 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(NET.ip_sockets[Defines.NS_SERVER]) + assertNull(network.serverTransport.udpEndpoint) assertFalse(host.isPaused) assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) host.close() + network.close() } @ParameterizedTest @@ -543,19 +570,24 @@ 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"]) assertSame(client, host.clients[0]) assertTrue(host.isRunning) - assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(network.serverTransport.udpEndpoint) 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")) @@ -615,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.ip_sockets[Defines.NS_SERVER] + val socket = network.serverTransport.udpEndpoint val output = StringBuilder() val sink = Com.ConsoleSink { _, message -> output.append(message) } var continued = false @@ -630,7 +662,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, 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 8ba391515..6876f2852 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) @@ -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 37ad89275..0753e2c57 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,17 @@ 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("autosave")) + assertInstanceOf(HostSaveResult.Rejected::class.java, host.deleteSave("autosave")) + } + @Test fun `host construction and invalid starts do not bootstrap server state`() { val cvars = Cvar.getInstance() 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 a017c64b7..ce9807700 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/core/build.gradle b/cake/core/build.gradle index 54ac5bc49..d449cca9f 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('')) +} 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 d4394d278..7dbfad6e0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -22,17 +22,17 @@ 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 jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.Netchan +import org.demoth.cake.network.ClientNetwork +import org.demoth.cake.network.ClientHandshake +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.NetworkSettings import jake2.qcommon.network.messages.ConnectionlessCommand import jake2.qcommon.network.messages.NetworkMessage 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.netchan_t +import jake2.qcommon.network.transport.NetworkAddress import jake2.qcommon.vfs.VfsDebugCommands import ktx.app.KtxApplicationAdapter import ktx.app.KtxInputAdapter @@ -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 @@ -55,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 @@ -76,9 +78,9 @@ 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 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" @@ -128,15 +130,12 @@ 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 netchan = netchan_t() + private val handshake = ClientHandshake(network, ::connectPayload) + private val localConnection get() = handshake.isLocal + 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 @@ -201,11 +200,16 @@ class Cake( private val uiSize = Cvar.getInstance().find("cl_ui_size") init { - Netchan.Netchan_Init() + NetworkSettings.registerCvars() } 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() @@ -232,6 +236,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) @@ -280,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") @@ -293,11 +301,11 @@ 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 = connectionTarget ?: return@AddCommand - val local = target.type == NetAddrType.NA_LOOPBACK + val target = handshake.lastTarget ?: return@AddCommand + val local = target is NetworkAddress.Local if (local && host.state != HostState.RUNNING) { Com.Warn("No local server is running.\n") return@AddCommand @@ -314,14 +322,14 @@ 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 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() @@ -355,46 +363,34 @@ 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()) { 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 && !NET.configureClient(true)) { + if (to !is NetworkAddress.Local && !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() } - - 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("menu_loadgame", "Open the Load Game menu") { + menuEventBus.postIntent(MenuIntent.OpenLoadGame) } - Cmd.AddCommand("host_stop", "Stop the local game and disconnect") { disconnect() } + 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) { beginMapTransitionRetainingConfigAssets() @@ -443,7 +439,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 } @@ -477,38 +473,32 @@ 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 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") } return result } - private fun beginConnection(target: netadr_t, local: Boolean) { - if (!local && !NET.configureClient(true)) { + private fun beginConnection(target: NetworkAddress, local: Boolean) { + if (!handshake.begin(target)) { Com.Warn("Could not open the client UDP socket\n") return } - if (local) NET.configureClient(false) Com.Printf("Connecting to ${if (local) "local server" else target}...\n") - connectionTarget = target - localConnection = local - challenge = 0 - reconnectTimeout = 0f networkState = CONNECTING } @@ -518,8 +508,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. */ @@ -534,17 +523,16 @@ class Cake( clearTransitionBackdrop() netchan.clear() networkState = DISCONNECTED - localConnection = false - challenge = 0 - reconnectTimeout = 0f + handshake.reset() clientCommandExtraMs = 0f downloads.reset() networkDebugSampler.clear() } private fun transmitAndRecord(unreliable: Collection?) { - netchan.transmit(unreliable) - networkDebugSampler.recordOutbound(netchan.last_sent_size, Globals.curtime) + if (netchan.transmit(unreliable)) { + networkDebugSampler.recordOutbound(netchan.lastSentSize, Globals.curtime) + } } private fun handleMissingResourceFailure(error: MissingResourceException) { @@ -684,9 +672,11 @@ 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) + beginConnection(network.localPeer, local = true) menuVisible = false consoleVisible = false } @@ -696,7 +686,7 @@ class Cake( } game3dScreen?.updateLocalInput(deltaSeconds) runClientCommandSteps(deltaSeconds) - CheckForResend(deltaSeconds) + handshake.tick(deltaSeconds) if (!drawable) return if (uiViewport.setReferenceHeight(uiSize.typedValue)) { @@ -710,8 +700,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) @@ -727,9 +717,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. @@ -741,7 +734,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() @@ -791,13 +784,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) } @@ -805,7 +798,7 @@ class Cake( ACTIVE -> { queueUserInfoUpdateIfNeeded() - game3dScreen?.buildMoveMessage(netchan.outgoing_sequence, currentClientCommandMsec())?.let { + game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec())?.let { transmitAndRecord(listOf(it)) } } @@ -846,7 +839,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 } } @@ -939,7 +932,11 @@ class Cake( override fun dispose() { closeUiSample() uiSample.dispose() - disconnect() + try { + disconnect() + } finally { + try { host.dispose() } finally { network.close() } + } profilePersistence.saveConfig() menuStages.dispose() consoleStage.dispose() @@ -983,6 +980,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 -> { @@ -1043,79 +1050,48 @@ 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 { - Netchan.sendConnectionlessPacket(NS_CLIENT, 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 - - val port = Cvar.getInstance().find("qport").typedValue + private fun connectPayload(challenge: Int): String { + val port = network.qport Globals.userinfo_modified = false - - Netchan.sendConnectionlessPacket( - NS_CLIENT, - adr, - ConnectionlessCommand.connect, - " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" - ) + return " $PROTOCOL_VERSION $port $challenge \"${Cvar.getInstance().Userinfo()}\"\n" } 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 - ) - - 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 // - if (networkPacket.from.type != netchan.remote_address.type || - !networkPacket.from.compareIp(netchan.remote_address)) { + if (networkPacket.from != netchan.remoteAddress) { Com.Warn(networkPacket.from.toString() + ": sequenced packet without connection\n") - continue + return@poll } if (netchan.accept(networkPacket)) { - networkDebugSampler.recordInbound(netchan.last_received_size, Globals.curtime) - parseServerMessage(networkPacket.parseBodyFromServer()) + networkDebugSampler.recordInbound(netchan.lastReceivedSize, Globals.curtime) + when (val body = networkPacket.parseBodyFromServer()) { + is MessageDecodeResult.Decoded -> { + 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") + } + 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 @@ -1130,8 +1106,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 -> @@ -1142,14 +1118,13 @@ 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") beginMapTransitionRetainingConfigAssets() networkState = CONNECTING - // CheckForResend() will fire immediately - reconnectTimeout = 0f + handshake.restart() } is StuffTextMessage -> { @@ -1171,7 +1146,7 @@ class Cake( // new game is starting Cbuf.Execute() resetClientStateForServerData() - netchan.reliablePending.clear() + netchan.clearQueuedReliable() menuStages.hideContentPreviews() fileResolver.gamemod = msg.gameName.takeIf { !it.isNullOrBlank() } @@ -1193,8 +1168,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 @@ -1297,7 +1272,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() @@ -1342,21 +1317,16 @@ 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)) + netchan.setup(packet.from, network.qport) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.NEW)) Com.Println("Connected!") } @@ -1483,12 +1453,23 @@ 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 + } + // Selection and hosted storage are committed before discarding outgoing gameplay inputs. + CakeHostStartupSettings.reset() applyGameProfile(profile) profilePersistence.loadConfig() return path @@ -1506,6 +1487,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 @@ -1567,6 +1549,32 @@ 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 { + 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) + + 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/CakeCommandParsing.kt b/cake/core/src/main/kotlin/org/demoth/cake/CakeCommandParsing.kt index bd1d7ce86..b2f6e1954 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/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt b/cake/core/src/main/kotlin/org/demoth/cake/download/CakeDownloadTransfer.kt index 05fa0e77a..1cb624612 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/hosting/CakeHost.kt b/cake/core/src/main/kotlin/org/demoth/cake/hosting/CakeHost.kt index 70683e7f8..ea76a7759 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,12 +1,24 @@ package org.demoth.cake.hosting import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.network.ClientNetwork +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 { + val clientNetwork: ClientNetwork 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. */ @@ -17,6 +29,17 @@ 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, 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 } enum class HostAvailability { REMOTE_ONLY, NOT_READY, AVAILABLE } @@ -43,11 +66,23 @@ 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() = clientNetwork.close() override fun start(request: HostRequest) = HostStartResult.Rejected("Hosting requires the Cake fullgame application.") 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, 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 new file mode 100644 index 000000000..1c586d5c9 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/hosting/HostSaves.kt @@ -0,0 +1,30 @@ +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 mapTitle: String? = null, +) { + val autosave: Boolean get() = slot == "autosave" +} + +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, 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/input/InputManager.kt b/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt index aefba6415..ae26f02c7 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/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 000000000..e24e8eed1 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientHandshake.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.network + +import jake2.qcommon.network.transport.NetworkAddress +import jake2.qcommon.network.messages.ConnectionlessCommand + +/** 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 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 + private var challenge = 0 + private var retryRemainingSeconds = 0f + + /** A failed bind leaves the current handshake untouched. */ + fun begin(address: NetworkAddress): Boolean { + val local = address is NetworkAddress.Local + if (!network.configure(!local)) return false + target = 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, ConnectionlessCommand.getchallenge, "\n") + retryRemainingSeconds = 1f + } else { + retryRemainingSeconds -= deltaSeconds + } + } + + fun receiveChallenge(from: NetworkAddress, value: String?): Boolean { + val peer = expected(from) ?: return false + val parsed = value?.toIntOrNull() ?: return false + challenge = parsed + sendConnect(peer) + return true + } + + fun acceptConnection(from: NetworkAddress): Boolean { + if (expected(from) == null) return false + pending = false + return true + } + + private fun expected(from: NetworkAddress): NetworkAddress? = target?.takeIf { pending && it == from } + + 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 new file mode 100644 index 000000000..a7c10f4c7 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/network/ClientNetwork.kt @@ -0,0 +1,43 @@ +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 +import jake2.qcommon.network.messages.NetworkPacket +import jake2.qcommon.network.runtime.NetworkTransport +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 { + 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 + fun newChannel(): ConnectionChannel + fun poll(budget: Int = PacketNetwork.DEFAULT_POLL_BUDGET, consume: (NetworkPacket) -> Unit): Int + fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) + fun stop() + override fun close() = stop() +} + +/** Owns the supplied transport; connections borrow it through the shared packet adapter. */ +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) + override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) + 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/profile/CakeHostStartupSettings.kt b/cake/core/src/main/kotlin/org/demoth/cake/profile/CakeHostStartupSettings.kt new file mode 100644 index 000000000..8864a0510 --- /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/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/HostingSetupStage.kt index 5a597fa5e..0e84946a2 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 a2f8ab388..0c2235c65 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 @@ -39,7 +39,6 @@ class MainMenuStage( ) { private var currentProfileLabel: Label private var switchProfileButton: TextButton - private var singleplayerButton: TextButton? = null private var disconnectButton: TextButton private var renderedProfileId: String = "" private var lastCanDisconnectState: Boolean = false @@ -67,10 +66,9 @@ class MainMenuStage( row() if (showHosting) { - singleplayerButton = createMenuButton("Singleplayer", buttonStyle) { - menuEventBus.postIntent(MenuIntent.OpenHostingSetup(true)) - } - add(singleplayerButton) + add(createMenuButton("Singleplayer", buttonStyle) { + menuEventBus.postIntent(MenuIntent.OpenSingleplayerMenu) + }.apply { name = "singleplayer-menu" }) row() } @@ -104,7 +102,6 @@ class MainMenuStage( override fun act(delta: Float) { super.act(delta) val snapshot = menuEventBus.latestState() - singleplayerButton?.isDisabled = !snapshot.canHostGame 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 d37d540fd..79c60130d 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,9 +49,12 @@ 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, 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 -> 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()) @@ -74,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() @@ -94,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 8724bc36f..2538cbce1 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, 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 new file mode 100644 index 000000000..38ff64db8 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SavedGamesStage.kt @@ -0,0 +1,225 @@ +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 +import org.demoth.cake.ui.menu.* +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** List and preview share a selection; IO and confirmation decisions belong to the controller. */ +class SavedGamesStage( + viewport: Viewport, + 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 { + val date = entry.savedAtMillis?.let { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(it)) + } ?: "Unknown date" + val map = entry.mapName ?: entry.slot + val title = entry.mapTitle?.replace(Regex("\\s+"), " ")?.trim()?.takeIf { it.isNotEmpty() } ?: map + return "$date - $title - $map" + } + } + + private var syncing = false + private var renderedEntries: List? = null + private var renderedSlot: String? = null + private val profile = Label("", style.menuWidgets.label) + 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(false, false) + setFadeScrollBars(false) + 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 { + 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 + 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.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(confirm).pad(4f) + add(action("Cancel", "cancel-save-confirmation") { + menuEventBus.postIntent(MenuIntent.CancelSaveConfirmation) + }).pad(4f) + } + private val confirmationCell: Cell + + 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 preview = Table().apply { + background = style.menuWidgets.controlPanel + name = "save-preview" + add(Stack(previewImage, noPreview)).grow().pad(4f) + } + 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)).row() + add(profile).row() + add(body).growX().row() + add(actions).growX().row() + confirmationCell = add(confirmation).growX().height(0f) + row() + add(status).width(756f).row() + } + addCenteredForm(root, style.menuWidgets.scrollPane) + scrollFocus = listScroll + keyboardFocus = slots + } + + 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.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 } + 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 { "" }}") + 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 + 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." + else -> listOfNotNull(if (selected?.autosave == true) "Autosave" else null, + selected?.comment, selected?.loadRejectionReason).joinToString("\n") + }) + status.setText(state.statusMessage.ifBlank { reason.orEmpty() }) + } + + override fun keyUp(keycode: Int): Boolean { + 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) + } + + /** 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/SingleplayerMenuStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt new file mode 100644 index 000000000..0d5c7be98 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/SingleplayerMenuStage.kt @@ -0,0 +1,42 @@ +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" } + + init { + addActor(createHubMenuTable().apply { + add(Label("Singleplayer", style.menuWidgets.label)).row() + add(newGame).row() + add(load).row() + add(save).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 + } +} 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 77d8edf42..4ed0423a2 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 @@ -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. @@ -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) { @@ -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.. 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/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 000000000..8a0abda2a --- /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 49e7206d7..a7dca6d26 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,9 +1,15 @@ 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 { + 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 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 = @@ -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,69 @@ class MenuController( private fun handleIntent(intent: MenuIntent) { when (intent) { + MenuIntent.OpenSingleplayerMenu -> refreshState(activeScreen = MenuScreen.SINGLEPLAYER) + MenuIntent.OpenLoadGame, MenuIntent.OpenSaveGame -> { + refreshSavedGames(saving = intent == MenuIntent.OpenSaveGame) + refreshState(activeScreen = if (intent == MenuIntent.OpenLoadGame) MenuScreen.LOAD_GAME else MenuScreen.SAVE_GAME) + bus.postSignal(MenuSignal.ShowMenu) + } + 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, pendingDeleteSlot = 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, pendingDeleteSlot = null, 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) + } + 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 -> @@ -83,7 +158,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) @@ -97,7 +172,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() } @@ -263,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, @@ -354,6 +431,50 @@ 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, + 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() + val status = when (catalog) { + 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}" + } + val selected = selectedSlot?.takeIf { state.savedGames.profileId == profileId && entries.any { entry -> entry.slot == it } } + ?: entries.firstOrNull { if (saving) !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, pendingDeleteSlot = null)) + refreshState() + } + + private fun handleSaveResult(result: HostSaveResult, loading: Boolean) { + when (result) { + is HostSaveResult.Completed -> { + refreshSavedGames(result.slot) + saveStatus(if (loading) "Loading game..." else listOfNotNull("Game saved.", result.warning).joinToString(" ")) + 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 +551,8 @@ class MenuController( state = MenuStateSnapshot( activeScreen = activeScreen, canHostGame = backend.hostingUnavailableReason() == null, + saveAccess = backend.saveAccess(), + savedGames = state.savedGames, singleplayer = state.singleplayer, hostGame = state.hostGame, mainMenu = mainMenuState, @@ -459,7 +582,8 @@ class MenuController( MenuScreen.MAIN -> 0 MenuScreen.PROFILE_EDIT, MenuScreen.MULTIPLAYER, MenuScreen.OPTIONS, MenuScreen.SINGLEPLAYER, MenuScreen.CONTENT_BROWSER -> 1 - MenuScreen.JOIN_GAME, MenuScreen.PLAYER_SETUP, MenuScreen.OPTIONS_SECTION, MenuScreen.HOST_GAME -> 2 + 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 1d5fcc816..acbbf04ba 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,16 @@ 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 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 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/MenuSignal.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt index f8e6fc150..b1d801129 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 5ca2be4c0..cfbda472e 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,10 +1,16 @@ 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, + NEW_GAME, HOST_GAME, JOIN_GAME, PLAYER_SETUP, @@ -73,6 +79,8 @@ data class OptionsSectionState( data class MenuStateSnapshot( val activeScreen: MenuScreen = MenuScreen.MAIN, val canHostGame: 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,15 @@ 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 = "", + val pendingDeleteSlot: String? = null, +) + /** 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/HostCommandParsingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/HostCommandParsingTest.kt deleted file mode 100644 index ec55b6691..000000000 --- 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)) - } - } -} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadSessionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadSessionTest.kt index 4496ba544..e0da72756 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadSessionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadSessionTest.kt @@ -26,17 +26,17 @@ class CakeDownloadSessionTest { assertTrue(downloads.requestMissingResource("maps/base1.bsp", null, 42)) assertEquals(listOf("download maps/base1.bsp"), sent) assertEquals(CakeDownloadSession.Result.Continue, - downloads.handle(DownloadMessage(byteArrayOf(1, 2), 50))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(1, 2), 50))) assertEquals(listOf("download maps/base1.bsp", "nextdl"), sent) assertFalse(Files.exists(temp.resolve("baseq2/maps/base1.bsp"))) assertEquals(CakeDownloadSession.Result.RetryPrecache(42), - downloads.handle(DownloadMessage(byteArrayOf(3), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(3), 100))) assertArrayEquals(byteArrayOf(1, 2, 3), Files.readAllBytes(temp.resolve("baseq2/maps/base1.bsp"))) assertFalse(Files.exists(temp.resolve("baseq2/maps/base1.tmp"))) assertEquals(2, sent.size, "Precache is returned to the caller, not sent to the server") downloads.precacheCompleted() - assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage(byteArrayOf(4), 100))) + assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage.chunk(byteArrayOf(4), 100))) assertArrayEquals(byteArrayOf(1, 2, 3), Files.readAllBytes(temp.resolve("baseq2/maps/base1.bsp"))) } @@ -50,7 +50,7 @@ class CakeDownloadSessionTest { downloads.requestMissingResource("maps/base1.bsp", "rogue", 7) assertEquals(listOf("download maps/base1.bsp 3"), sent) assertEquals(CakeDownloadSession.Result.RetryPrecache(7), - downloads.handle(DownloadMessage(byteArrayOf(4), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(4), 100))) assertArrayEquals(byteArrayOf(1, 2, 3, 4), Files.readAllBytes(temp.resolve("rogue/maps/base1.bsp"))) assertFalse(Files.exists(temp.resolve("baseq2/maps/base1.bsp"))) } @@ -64,10 +64,10 @@ class CakeDownloadSessionTest { assertEquals(1, sent.size) assertEquals(CakeDownloadSession.Result.Continue, - downloads.handle(DownloadMessage(byteArrayOf(1), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(1), 100))) assertEquals(listOf("download maps/base1.bsp", "download sound/world/door.wav"), sent) assertEquals(CakeDownloadSession.Result.RetryPrecache(3), - downloads.handle(DownloadMessage(byteArrayOf(2), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(2), 100))) assertEquals(2, sent.size) } @@ -75,19 +75,19 @@ class CakeDownloadSessionTest { fun `reset cancels active and queued work but retains partial file for reconnect`() { val downloads = session() downloads.requestMissingResource("maps/base1.bsp", null, 1) - downloads.handle(DownloadMessage(byteArrayOf(1, 2), 50)) + downloads.handle(DownloadMessage.chunk(byteArrayOf(1, 2), 50)) downloads.requestMissingResource("sound/world/door.wav", null, 1) downloads.reset() sent.clear() assertEquals(CakeDownloadSession.Result.Continue, - downloads.handle(DownloadMessage(byteArrayOf(9), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(9), 100))) assertTrue(sent.isEmpty()) assertArrayEquals(byteArrayOf(1, 2), Files.readAllBytes(temp.resolve("baseq2/maps/base1.tmp"))) downloads.requestMissingResource("maps/base1.bsp", null, 99) assertEquals(listOf("download maps/base1.bsp 2"), sent) assertEquals(CakeDownloadSession.Result.RetryPrecache(99), - downloads.handle(DownloadMessage(byteArrayOf(3), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(3), 100))) assertEquals(1, sent.size, "Reset must discard the old queued sound") } @@ -96,11 +96,11 @@ class CakeDownloadSessionTest { val downloads = session() downloads.requestMissingResource("maps/base1.bsp", null, 5) downloads.requestMissingResource("sound/world/door.wav", null, 5) - assertEquals(CakeDownloadSession.Result.MissingOnServer, downloads.handle(DownloadMessage())) + assertEquals(CakeDownloadSession.Result.MissingOnServer, downloads.handle(DownloadMessage.Failure())) assertEquals(listOf("download maps/base1.bsp"), sent) // Cake responds to failure by disconnecting, which resets this session. downloads.reset() - assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage())) + assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage.Failure())) assertFalse(Files.exists(temp.resolve("baseq2/maps/base1.bsp"))) } @@ -114,7 +114,7 @@ class CakeDownloadSessionTest { cvars.setResult("allow_download", "0") assertFalse(downloads.requestMissingResource("sound/world/door.wav", null, 1)) assertTrue(sent.isEmpty()) - assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage())) + assertEquals(CakeDownloadSession.Result.Continue, downloads.handle(DownloadMessage.Failure())) } @Test @@ -123,6 +123,6 @@ class CakeDownloadSessionTest { downloads.requestMissingResource("maps/base1.bsp", null, 10) downloads.precacheCompleted() assertEquals(CakeDownloadSession.Result.Continue, - downloads.handle(DownloadMessage(byteArrayOf(1), 100))) + downloads.handle(DownloadMessage.chunk(byteArrayOf(1), 100))) } } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadTransferTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadTransferTest.kt index 069f2c655..ee0322182 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadTransferTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/download/CakeDownloadTransferTest.kt @@ -44,7 +44,7 @@ class CakeDownloadTransferTest { val request = request("maps/base1.bsp") transfer.begin(request) - val result = transfer.handle(DownloadMessage(byteArrayOf(1, 2), 25)) + val result = transfer.handle(DownloadMessage.chunk(byteArrayOf(1, 2), 25)) val continued = assertInstanceOf(CakeDownloadTransferResult.Continue::class.java, result) assertEquals(25, continued.percent) @@ -58,9 +58,9 @@ class CakeDownloadTransferTest { val transfer = CakeDownloadTransfer() val request = request("models/items/armor/tris.md2") transfer.begin(request) - transfer.handle(DownloadMessage(byteArrayOf(1, 2), 50)) + transfer.handle(DownloadMessage.chunk(byteArrayOf(1, 2), 50)) - val result = transfer.handle(DownloadMessage(byteArrayOf(3, 4), 100.toByte())) + val result = transfer.handle(DownloadMessage.chunk(byteArrayOf(3, 4), 100)) assertInstanceOf(CakeDownloadTransferResult.Completed::class.java, result) assertTrue(Files.isRegularFile(request.finalPath)) @@ -75,12 +75,23 @@ class CakeDownloadTransferTest { val request = request("sound/world/doors.wav") transfer.begin(request) - val result = transfer.handle(DownloadMessage()) + val result = transfer.handle(DownloadMessage.Failure()) assertInstanceOf(CakeDownloadTransferResult.MissingOnServer::class.java, result) assertNull(transfer.activeRequest()) } + @Test + fun `empty final chunk creates an empty file rather than reporting failure`() { + val transfer = CakeDownloadTransfer() + val request = request("sound/world/empty.wav") + transfer.begin(request) + val result = transfer.handle(DownloadMessage.chunk(byteArrayOf(), 100)) + assertInstanceOf(CakeDownloadTransferResult.Completed::class.java, result) + assertEquals(0L, Files.size(request.finalPath)) + assertNull(transfer.activeRequest()) + } + private fun request(logicalPath: String): CakeDownloadRequest { return CakeDownloadRequest( gameDirectory = "baseq2", 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 7e4faf91b..8e65c9650 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/network/ClientHandshakeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt new file mode 100644 index 000000000..da590cc40 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientHandshakeTest.kt @@ -0,0 +1,146 @@ +package org.demoth.cake.network + +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 +import org.junit.jupiter.api.Assertions.* +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() + var bindSucceeds = true + override fun configure(multiplayer: Boolean): Boolean { + binds += multiplayer + return bindSucceeds + } + 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: 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) = 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`() { + val network = Network() + var userinfo = "first" + val handshake = ClientHandshake(network) { "$it $userinfo" } + val peer = remote() + assertTrue(handshake.begin(peer)) + 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())) + 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)) + } + 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 = 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(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!!)) + 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()) + 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()) + network.bindSucceeds = false + assertFalse(handshake.begin(remote(12345))) + assertEquals(27910, (handshake.lastTarget as NetworkAddress.Udp).port) + handshake.tick(0f) + 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 new file mode 100644 index 000000000..62504880f --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/network/ClientNetworkTest.kt @@ -0,0 +1,49 @@ +package org.demoth.cake.network + +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.NetworkSettings +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() + NetworkSettings.registerCvars() + 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(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(channel.remoteAddress, 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() + } + } +} 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 c600821dc..177536558 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/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/InputManagerAngleSyncTest.kt index 0b6e6630d..0916ec25d 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 1c1065501..ba14e96c1 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 2a0c12cbc..c126c9cf0 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/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 0e5f44bfb..7bbf65f49 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,9 +4,16 @@ 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.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 import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertTrue import org.demoth.cake.CakeCvars import org.junit.jupiter.api.Test @@ -14,6 +21,142 @@ 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(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)) + 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 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 playerDeltasInheritTheAcknowledgedFrameAndResetSelectedFieldsWithoutSharingArrays() { + CakeCvars.registerEntities() + val manager = ClientEntityManager() + val initial = player_state_t().apply { + pmove.origin = shortArrayOf(8, 16, 24) + viewoffset[2] = 2.25f + viewangles[1] = -90f + gunindex = 1; gunframe = 3; gunoffset[0] = 1.5f + blend[0] = 1f + stats[0] = 12; stats[1] = 25; stats[31] = -1 + } + fun receive(frame: Int, base: Int, message: PlayerInfoMessage) { + manager.processServerFrameHeader(FrameHeaderMessage(frame, base, 0, 0, byteArrayOf())) + manager.processPlayerInfoMessage(roundTrip(message)) + assertTrue(manager.processPacketEntitiesMessage(PacketEntitiesMessage(emptyList()))) + } + try { + receive(1, -1, PlayerInfoMessage.fromGame(player_state_t(), initial)) + val first = manager.currentFrame.playerstate.getClone() + val next = initial.getClone().apply { + pmove.origin[0] = 0; viewoffset[2] = 0f + stats[0] = 0; stats[31] = 0; blend[0] = 0f + } + 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) + assertArrayEquals(first.gunoffset, second.gunoffset) + assertArrayEquals(FloatArray(4), second.blend) + assertEquals(listOf(0, 25, 0), listOf(second.stats[0].toInt(), second.stats[1].toInt(), second.stats[31].toInt())) + // Frame 3 deltas from acknowledged frame 1, so absent fields recover frame 1's values. + receive(3, 1, PlayerInfoMessage(weaponIndex = 2)) + val third = manager.currentFrame.playerstate + assertArrayEquals(first.pmove.origin, third.pmove.origin) + assertArrayEquals(first.viewoffset, third.viewoffset) + assertArrayEquals(first.stats, third.stats) + assertEquals(2, third.gunindex) + // Mutating the new engine frame must not alter retained history. + third.pmove.origin.fill(99); third.viewoffset.fill(99f); third.stats.fill(99) + receive(4, 1, PlayerInfoMessage()) + 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() + } + } + + @Test + fun shorterAreaBitsClearThePreviousVisibilityTail() { + CakeCvars.registerEntities() + val manager = ClientEntityManager() + try { + val size = manager.currentFrame.areabits.size + manager.processServerFrameHeader(FrameHeaderMessage(1, -1, 0, size, ByteArray(size) { -1 })) + manager.processServerFrameHeader(FrameHeaderMessage(2, -1, 0, 1, byteArrayOf(3))) + assertArrayEquals(ByteArray(size).apply { this[0] = 3 }, manager.currentFrame.areabits) + manager.processServerFrameHeader(FrameHeaderMessage(3, -1, 0, 0, byteArrayOf())) + assertArrayEquals(ByteArray(size), manager.currentFrame.areabits) + } finally { + manager.dispose() + } + } + @Test fun previousFrameTracksImmediatePriorServerFrameForInterpolation() { CakeCvars.registerEntities() @@ -40,13 +183,13 @@ class ClientEntityManagerFrameSelectionTest { manager.processServerFrameHeader(FrameHeaderMessage(frameNumber, lastFrame, 0, 0, byteArrayOf())) manager.processPlayerInfoMessage( roundTrip( - PlayerInfoMessage( + PlayerInfoMessage.fromGame( playerState(previousGunFrame), playerState(currentGunFrame), ), ), ) - assertTrue(manager.processPacketEntitiesMessage(PacketEntitiesMessage())) + assertTrue(manager.processPacketEntitiesMessage(PacketEntitiesMessage(emptyList()))) } private fun playerState(gunFrame: Int): player_state_t { @@ -57,7 +200,7 @@ class ClientEntityManagerFrameSelectionTest { } // The runtime receives parsed network messages, not freshly constructed DTOs. Round-tripping - // here forces PlayerInfoMessage to compute/write its delta flags first so the test exercises + // here exercises the immutable player delta codec so the test receives // the same decoded shape that processPlayerInfoMessage sees in production. private fun roundTrip(message: PlayerInfoMessage): PlayerInfoMessage { val buffer = sizebuf_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 8a2901461..85a8ceb85 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/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt index f9f4e4a52..501de06e3 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,13 @@ 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.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 +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 +72,8 @@ class MenuStagesGlTest { val menus = MenuStages(viewport, bus, assets, CakeFileResolver(), false) { null } try { verifyHostMapSelection(viewport, style) + verifySavedGames(viewport, style) + verifySingleplayerHub(viewport, style) menus.rebuild(style) val main = menus.inputProcessor assertFalse(menus.select(MenuScreen.MAIN)) @@ -142,6 +151,131 @@ class MenuStagesGlTest { } } + private fun verifySavedGames(viewport: UiViewport, style: GameUiStyle) { + val entries = listOf( + 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, "autosave"))) + 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() + 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()) + assertTrue(previewPosition.x >= listPosition.x + scroll.width) + 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 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) + 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.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("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) + 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, + 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, + 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/SavePreviewTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/SavePreviewTest.kt new file mode 100644 index 000000000..fe9ff225b --- /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 d210862ae..239c91e70 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,214 @@ 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 { + @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) + + @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")) } + 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")) } + 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("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", "autosave")) + assertNull(bus.latestState().savedGames.pendingOverwriteSlot) + post(MenuIntent.SaveGameRequested("default", "manual")) + assertEquals("manual", bus.latestState().savedGames.pendingOverwriteSlot) + assertTrue(backend.savesWritten.isEmpty()) + post(MenuIntent.CancelSaveConfirmation) + 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.OpenLoadGame) + 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 +650,35 @@ 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>() + var saveWarning: String? = null + 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++ + return catalogFailure ?: HostSaveCatalogResult.Available(activeProfileId(), saves) + } + override fun saveGame(slot: String?, overwrite: Boolean): HostSaveResult { + savesWritten += slot to overwrite + return HostSaveResult.Completed(slot ?: "new", saveWarning) + } + override fun loadGame(slot: String): HostSaveResult { + loads += slot + return loadOutcome + } var maps = emptyList() var mapReads = 0 override fun hostMaps(): List { 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 d363d2b4f..dc706c957 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( diff --git a/client/src/main/java/jake2/client/CL.java b/client/src/main/java/jake2/client/CL.java index fee8a7c53..386d4bd7f 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 @@ -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/client/src/main/java/jake2/client/client_static_t.java b/client/src/main/java/jake2/client/client_static_t.java index 9f59a226e..b69bb40d7 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/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index dadbf5591..6fe1f0707 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; @@ -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; @@ -55,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); @@ -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 ed7f77cbc..53b6f5a09 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,12 +16,20 @@ 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() MainCommon.registerCvars(true) Cmd.Init() - jake2.qcommon.network.Netchan.Netchan_Init() + jake2.qcommon.network.NetworkSettings.registerCvars() Cvar.getInstance().find("port").forceSet(0) } @@ -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.ip_sockets[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.ip_sockets[jake2.qcommon.Defines.NS_SERVER]) + assertNull(transport.udpEndpoint) } } diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 2ccc4fb9a..02f391da8 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); } } @@ -1148,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; } @@ -1166,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 { @@ -1176,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/game/src/main/kotlin/jake2/game/LevelSaveJson.kt b/game/src/main/kotlin/jake2/game/LevelSaveJson.kt index 7b1440169..062fcc9a9 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/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 000000000..87399d3b3 --- /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/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt b/game/src/test/kotlin/jake2/game/LevelSaveJsonTest.kt index 0b249c1a5..5a16f50e1 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 } diff --git a/info/Networking.md b/info/Networking.md index d909ae7e7..6e2faf60f 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -1,36 +1,97 @@ # 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 each own a separate transport. +- 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 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 (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. +- **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. + +### Non Sequenced / Connectionless / out-of-band packets -## "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. -Mostly such packets contain a single string (though there are some cases when 2 strings are sent - `info` & `print`) +These are independent datagrams that can be sent by connected or unconnected peers, +without requiring a running map: -see `jake2.qcommon.network.messages.ConnectionlessCommand` +- **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 -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` @@ -45,45 +106,54 @@ 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 participant server Note over client: User types connect hostname - client->>server: getchallenge - server->>client: challenge - client->>server: connect [connectionless] + opt Remote UDP connection + client->>server: [c] getchallenge + server->>client: [c] challenge + end + client->>server: [c] connect Note over server: client.state = connected - server->>client: client_connect [connectionless] - client->>server: StringCmdMessage new + server->>client: [c] client_connect Note over client: state = connected - 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 until everything is downloaded - client->>server: StringCmdMessage download - server->>client: DownloadMessage + loop for each missing file + client->>server: [s] StringCmdMessage download path [offset] + server->>client: [s] DownloadMessage first chunk + loop remaining chunks of this file + 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 Every frame - server->>client: FrameHeaderMessage - server->>client: PlayerInfoMessage - server->>client: PacketEntitiesMessage - client->>server: MoveMessage + loop During gameplay, when updates are sent + server->>client: [s] FrameHeaderMessage + server->>client: [s] PlayerInfoMessage + server->>client: [s] PacketEntitiesMessage + client->>server: [s] MoveMessage end ``` diff --git a/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java b/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java index b98a1cdd9..4cdab2da6 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; 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 8e585824a..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ /dev/null @@ -1,329 +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.Defines; -import jake2.qcommon.Globals; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.exec.CvarTypes; -import jake2.qcommon.network.messages.NetworkPacket; - -import java.net.DatagramSocket; -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. */ - 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(); - } - } - - } - - 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; - } - - /** - * 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++; - - return NetworkPacket.fromLoopback(fromClient, loop.msgs[i].data, loop.msgs[i].datalen); - } - - public static NetworkPacket receiveNetworkPacket(DatagramSocket socket, DatagramChannel channel, loopback_t loopback, boolean fromClient) { - NetworkPacket result = receiveNetworkPacketLoopback(loopback, fromClient); - - if (result != null) { - return result; - } - - if (socket == null) // why? - return null; - - try { - byte[] bytes = new byte[MAX_MSGLEN]; - 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? - return null; - - return NetworkPacket.fromSocket(fromClient, bytes, receiveBuffer.position(), srcSocket.getAddress().getAddress(), srcSocket.getPort()); - - } catch (Exception e) { - return null; - } - } - - - /** - * Sends a Packet. - */ - public static void SendPacket(int sock, int length, byte[] data, netadr_t to) { - if (to.type == NetAddrType.NA_LOOPBACK) { - SendLoopPacket(sock, length, data, to); - return; - } - - if (ip_sockets[sock] == 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()); - } - } - - /** - * 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(); - 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 (ip_sockets[role] != null && !ip_sockets[role].isClosed()) 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; - } - - private static void closeRole(int role) { - if (ip_sockets[role] != null) ip_sockets[role].close(); - ip_sockets[role] = null; - if (ip_channels[role] != null) { - try { - ip_channels[role].close(); - } catch (java.io.IOException error) { - Com.Println("Closing UDP channel: " + 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); - } - - /** 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; - } - - /** Legacy combined policy for callers that own both socket roles. */ - public static void Config(boolean multiplayer) { - if (multiplayer) OpenIP(); - else { - configureClient(false); - configureServer(false); - } - } - - /* - * Socket - */ - public static DatagramSocket Socket(int sock, String ip, int port) { - DatagramSocket newsocket = null; - try { - if (ip_channels[sock] == null || !ip_channels[sock].isOpen()) - ip_channels[sock] = DatagramChannel.open(); - - 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)); - } - } else { - InetAddress ia = InetAddress.getByName(ip); - newsocket = ip_channels[sock].socket(); - newsocket.bind(new InetSocketAddress(ia, port)); - } - - // 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; - } - return newsocket; - } - - /** - * 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 (ip_sockets[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) { - } - //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); - */ - } -} \ No newline at end of file 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 998bdbf58..000000000 --- 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/Netchan.java b/qcommon/src/main/java/jake2/qcommon/network/Netchan.java deleted file mode 100644 index da908f54e..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/Netchan.java +++ /dev/null @@ -1,139 +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.network.messages.ConnectionlessCommand; -import jake2.qcommon.sizebuf_t; -import jake2.qcommon.sys.Timer; -import jake2.qcommon.util.Lib; - -/** - * 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 to the new value 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(); - NET.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"); - } - - /** - * 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/messages/NetworkMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java index 606f1985b..b4dbff9fb 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkMessage.java @@ -6,7 +6,10 @@ public interface NetworkMessage { void writeTo(sizebuf_t buffer); - void parse(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/NetworkPacket.java b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java index e323ee399..d15486a3f 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java +++ b/qcommon/src/main/java/jake2/qcommon/network/messages/NetworkPacket.java @@ -1,24 +1,16 @@ 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.netadr_t; +import jake2.qcommon.network.messages.server.ServerMessage; +import jake2.qcommon.network.protocol.MessageDecodeResult; +import jake2.qcommon.network.protocol.MessageBodyCodec; import jake2.qcommon.sizebuf_t; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; public class NetworkPacket { - - // should parse port - private final boolean fromClient; + private PacketEnvelope envelope; // Long, -1 for connectionless public int sequence; @@ -35,111 +27,52 @@ 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(); - 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; - networkPacket.buffer.data[datalen] = 0; // set the sentinel - networkPacket.parseHeader(); - return networkPacket; + /** 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(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 (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(NetworkAddress source) { + this.from = java.util.Objects.requireNonNull(source); } 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 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) { - 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 e5bad31ff..a63b820e5 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 @@ -3,6 +3,7 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.sizebuf_t; +/** Client message identity with wire operations delegated to the separate codec. */ public abstract class ClientMessage implements NetworkMessage { public final ClientMessageType type; @@ -11,40 +12,21 @@ 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); + @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); + } public static ClientMessage parseFromBuffer(sizebuf_t buffer, int incomingSequence) { - ClientMessageType type = ClientMessageType.fromByte((byte) buffer.readByte()); - 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: - // todo: notify somehow in case of unexpected type - msg = null; - } - 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/ClientMessageType.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/ClientMessageType.java index 97a0533ae..6b51a956b 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/client/EndOfClientPacketMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/EndOfClientPacketMessage.java deleted file mode 100644 index d1eb07a91..000000000 --- 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 deleted file mode 100644 index 225f2d7a8..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/client/MoveMessage.java +++ /dev/null @@ -1,234 +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; - } - - @Override - protected void writeProperties(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 - ); - } - - @Override - 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; - } - - @Override - public int getSize() { - 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/messages/client/NoopMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/client/NoopMessage.java deleted file mode 100644 index d57f5b1b4..000000000 --- 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 8926d2db1..000000000 --- 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 fe90a633f..000000000 --- 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/BeamOffsetTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/BeamOffsetTEMessage.java deleted file mode 100644 index 1388456f2..000000000 --- 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 a42770fca..000000000 --- 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/ConfigStringMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java deleted file mode 100644 index 6827440fa..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/ConfigStringMessage.java +++ /dev/null @@ -1,100 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -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(); - 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/DeltaEntityHeader.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DeltaEntityHeader.java deleted file mode 100644 index a2b0f8f33..000000000 --- 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/DisconnectMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/DisconnectMessage.java deleted file mode 100644 index 0683c22a4..000000000 --- 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 deleted file mode 100644 index d2436384e..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java +++ /dev/null @@ -1,82 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; - -/** - * Transmits chunk of media (maps, skins, sounds, etc) - * - * Fixme: Warning: data.length is limited by signed short (32k) - */ -public class DownloadMessage extends ServerMessage { - - public byte[] data; - public byte percentage; - - public DownloadMessage() { - super(ServerMessageType.svc_download); - } - - public DownloadMessage(byte[] data, byte percentage) { - this(); - this.data = data; - this.percentage = percentage; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - if (data != null) { - buffer.writeShort(data.length); - buffer.writeByte(percentage); - buffer.writeBytes(data, data.length); - } else { - buffer.writeShort(-1); - buffer.writeByte((byte) 0); - } - } - - @Override - public void parse(sizebuf_t buffer) { - int size = buffer.readShort(); - percentage = (byte) buffer.readByte(); - // fixme: signed or unsigned? - if (size != -1 && size != -32640) { - data = new byte[size]; - buffer.readData(data, size); - } - } - - @Override - public int getSize() { - if (data != null) - return 4 + data.length; - else - return 4; - } - - @Override - public String toString() { - return "DownloadMessage{" + - "percentage=" + percentage + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DownloadMessage that = (DownloadMessage) o; - - if (percentage != that.percentage) return false; - return Arrays.equals(data, that.data); - } - - @Override - public int hashCode() { - int result = Arrays.hashCode(data); - result = 31 * result + percentage; - return result; - } -} 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 7f0a87e03..000000000 --- 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/EntityUpdate.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/EntityUpdate.java deleted file mode 100644 index dcb57aef6..000000000 --- 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/FrameHeaderMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java deleted file mode 100644 index 34a6cca92..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/FrameHeaderMessage.java +++ /dev/null @@ -1,97 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; - -import static jake2.qcommon.Defines.MAX_MAP_AREAS; - -/** - * Generic information about frame. - * Sent along together with {@link PacketEntitiesMessage} and {@link PacketEntitiesMessage} every server frame. - */ -public class FrameHeaderMessage extends ServerMessage { - public int frameNumber; - public int lastFrame; - public int suppressCount; - private int areaBitsLength; - public byte[] areaBits; - - public FrameHeaderMessage() { - super(ServerMessageType.svc_frame); - } - - /** - * @param lastFrame what we are delta'ing from - * @param suppressCount rate dropped packets - */ - public FrameHeaderMessage(int frameNumber, int lastFrame, int suppressCount, int areaBitsLength, byte[] areaBits) { - this(); - this.frameNumber = frameNumber; - this.lastFrame = lastFrame; - this.suppressCount = suppressCount; - this.areaBitsLength = areaBitsLength; - this.areaBits = areaBits; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - buffer.writeInt(frameNumber); - // what we are delta'ing from - buffer.writeInt(lastFrame); - // rate dropped packets - buffer.writeByte((byte) suppressCount); - buffer.writeByte((byte) areaBitsLength); - buffer.writeBytes(areaBits, areaBitsLength); - } - - @Override - public void parse(sizebuf_t buffer) { - frameNumber = buffer.readInt(); - lastFrame = buffer.readInt(); - // BIG HACK to let old demos continue to work - // if (ClientGlobals.cls.serverProtocol != 26) - // fixme: do not read otherwise? - suppressCount = buffer.readByte(); - areaBitsLength = buffer.readByte(); - areaBits = new byte[MAX_MAP_AREAS / 8]; - buffer.readData(areaBits, areaBitsLength); - } - - @Override - public int getSize() { - return 1 + 10 + areaBitsLength; - } - - @Override - public String toString() { - return "FrameMessage{" + - "frameNumber=" + frameNumber + - ", lastFrame=" + lastFrame + - ", suppressCount=" + suppressCount + - ", areaBitsLength=" + areaBitsLength + - ", areaBits=" + Arrays.toString(areaBits) + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - FrameHeaderMessage that = (FrameHeaderMessage) o; - - if (frameNumber != that.frameNumber) return false; - if (lastFrame != that.lastFrame) return false; - if (suppressCount != that.suppressCount) return false; - if (areaBitsLength != that.areaBitsLength) return false; - - // due to custom equals implementation - didn't bother with hashcode - for (int i = 0; i < areaBitsLength; i++) { - if (areaBits[i] != that.areaBits[i]) - return false; - } - return true; - } - -} 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 deleted file mode 100644 index eb8beb5e7..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/InventoryMessage.java +++ /dev/null @@ -1,67 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.Defines; -import jake2.qcommon.sizebuf_t; - -import java.util.Arrays; - -/** - * Inventory sent to client. - * 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 int[] inventory; - - public InventoryMessage() { - super(ServerMessageType.svc_inventory); - } - - public InventoryMessage(int[] inventory) { - this(); - this.inventory = inventory; - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - for (int i = 0; i < Defines.MAX_ITEMS; i++) { - buffer.writeShort(inventory[i]); - } - - } - - @Override - public void parse(sizebuf_t buffer) { - this.inventory = new int[Defines.MAX_ITEMS]; - for (int i = 0; i < Defines.MAX_ITEMS; i++) { - this.inventory[i] = buffer.readShort(); - } - } - - @Override - public int getSize() { - return 1 + 2 * Defines.MAX_ITEMS; - } - - @Override - public String toString() { - return "InventoryMessage{" + - "inventory=" + Arrays.toString(inventory) + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - InventoryMessage that = (InventoryMessage) o; - - return Arrays.equals(inventory, that.inventory); - } - - @Override - public int hashCode() { - return Arrays.hashCode(inventory); - } -} 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 914af4f4c..000000000 --- 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/MuzzleFlash2Message.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java deleted file mode 100644 index b245dd3ef..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/MuzzleFlash2Message.java +++ /dev/null @@ -1,65 +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 ServerMessage { - 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(); - 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/NopMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/NopMessage.java deleted file mode 100644 index bf7da8f0d..000000000 --- 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 deleted file mode 100644 index 1c22dbfe7..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/PacketEntitiesMessage.java +++ /dev/null @@ -1,114 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.*; - -import java.util.ArrayList; -import java.util.List; - -/** - * Contains state changes for multiple entities. - */ -public class PacketEntitiesMessage extends ServerMessage { - - public final List updates = new ArrayList<>(); - - public PacketEntitiesMessage() { - super(ServerMessageType.svc_packetentities); - } - - @Override - protected void writeProperties(sizebuf_t buffer) { - for (EntityUpdate u : updates) { - // During serialization we don't have a header for entity state changes - if (u.header == null) { - // entity is changed - DeltaUtils.WriteDeltaEntity(u.oldState, u.newState, buffer, u.force, u.isNewEntity); - } 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); - - } - } - // 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) - Com.Error(Defines.ERR_DROP, "CL_ParsePacketEntities: bad number:" + header.number); - - if (buffer.readcount > buffer.cursize) - Com.Error(Defines.ERR_DROP, "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) { - // entity is changed - return DeltaUtils.getDeltaSize(value.oldState, value.newState, value.isNewEntity); - } 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; - } - } - - @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/PointDirectionTEMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PointDirectionTEMessage.java deleted file mode 100644 index fdd1f0e90..000000000 --- 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 4e334ce54..000000000 --- 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/PrintCenterMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/PrintCenterMessage.java deleted file mode 100644 index 1c760757c..000000000 --- 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 8b3622e90..000000000 --- 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 6905a6dd6..000000000 --- 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 deleted file mode 100644 index b3a54c38a..000000000 --- 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 ServerMessage { - 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 e603dd05d..a0875bdf2 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; @@ -9,208 +7,23 @@ * Common parent for all messages sent from server to client. */ public abstract class ServerMessage implements NetworkMessage { - public ServerMessageType type; + public final ServerMessageType type; 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(); - - return new DeltaEntityHeader(flags, 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) { - 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) { - - final int cmd = buffer.readByte(); - if (cmd == -1) - return new EndOfServerPacketMessage(); - ServerMessageType type = ServerMessageType.fromInt(cmd); - final ServerMessage msg; - // skip parsing of messages not yet migrated to the ServerMessage class - switch (type) { - case svc_bad: - case svc_nop: - default: - msg = null; - break; - 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; - case svc_playerinfo: - msg = new PlayerInfoMessage(); - break; - case svc_spawnbaseline: - msg = new SpawnBaselineMessage(); - break; - case svc_packetentities: - msg = new PacketEntitiesMessage(); - break; - case svc_download: - msg = new DownloadMessage(); - 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 IllegalStateException("Unexpected temp entity type:" + subtype); - } - } - - if (msg != null) { - msg.parse(buffer); - } - return msg; + return ServerMessageCodec.read(buffer); } + } 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 ef192024c..847750e0e 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 deleted file mode 100644 index 97ee01d5f..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java +++ /dev/null @@ -1,187 +0,0 @@ -package jake2.qcommon.network.messages.server; - -import jake2.qcommon.Com; -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.1 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 ServerMessage { - public int flags; - public int soundIndex; - public float volume; - public float attenuation; - public float timeOffset; - 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; - this.timeOffset = timeOffset; - this.sendchan = sendchan; - 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) (timeOffset * 1000)); - - if ((flags & Defines.SND_ENT) != 0) - buffer.writeShort(sendchan); - - 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) - timeOffset = buffer.readByte() / 1000.0f; - else - timeOffset = 0; - - 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); - - 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 (Float.compare(that.timeOffset, timeOffset) != 0) 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 + (timeOffset != +0.0f ? Float.floatToIntBits(timeOffset) : 0); - 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=" + timeOffset + - ", 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 deleted file mode 100644 index 3d1c24d2e..000000000 --- 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 ServerMessage { - - 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); - } - - @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/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 af2b6c6db..000000000 --- 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/StuffTextMessage.java b/qcommon/src/main/java/jake2/qcommon/network/messages/server/StuffTextMessage.java deleted file mode 100644 index 93c44efb7..000000000 --- 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 deleted file mode 100644 index 951e35b4d..000000000 --- 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 ServerMessage { - 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 85803f68a..000000000 --- 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/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 655117f12..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/messages/server/WeaponSoundMessage.java +++ /dev/null @@ -1,69 +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 ServerMessage { - - /** - * @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(); - 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/java/jake2/qcommon/network/netadr_t.java b/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java deleted file mode 100644 index b933ce408..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/netadr_t.java +++ /dev/null @@ -1,144 +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 static jake2.qcommon.network.NET.net_local_adr; - -public class netadr_t { - - public NetAddrType type; - - public int port; - - public byte[] ip; - - /** - * @param s String address, like 10.132.12.54:4312 - * @return 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; - } 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 - try { - // localhost / 127.0.0.1 - this.ip = InetAddress.getByName(null).getAddress(); - } catch (UnknownHostException e) { - } - } - - /** - * Seems to return true, if the address is is on 127.0.0.1. - */ - public boolean IsLocalAddress() { - return this.compareIp(net_local_adr); - } - - 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); - } - /** - * Compares ip address without the port. - * Returns true for a LOOPBACK type - assuming that singleplayer client is the first one - */ - 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 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 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; - } - } - - 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]; - } - - 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/java/jake2/qcommon/network/netchan_t.java b/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java deleted file mode 100644 index fecfe2daa..000000000 --- a/qcommon/src/main/java/jake2/qcommon/network/netchan_t.java +++ /dev/null @@ -1,271 +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.sizebuf_t; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -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; // qport value to write when transmitting - - // sequencing variables - 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 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 last_received_size; - - public int last_sent_size; - - public int smoothed_ping_ms; - - private final Map sendTimesBySequence = new HashMap<>(); - - /** - * 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<>(); - - // message is copied to this buffer when it is first transfered - public sizebuf_t reliableUnacknowledged = new sizebuf_t(Defines.MAX_MSGLEN - 16); - - /** - * 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; - last_received = Globals.curtime; - incoming_sequence = 0; - outgoing_sequence = 1; - } - - // 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; - 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) { - 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; - } - - 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"); - } - } - - 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)); - } - - } - - /** - * Netchan_CanReliable. Returns true if the last reliable message has acked. - */ - public boolean canReliable() { - return reliableUnacknowledged.cursize == 0; // waiting for ack - } - - /** - * 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; - } - - //ok. - public void clear() { - sock = dropped = 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(); - } - -} diff --git a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java index 842491ca5..2fda7381f 100644 --- a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java +++ b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java @@ -28,13 +28,15 @@ 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; /** * 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; @@ -167,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. @@ -183,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) { @@ -290,4 +294,4 @@ public void clear() { overflowed = false; } -} \ 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 new file mode 100644 index 000000000..384873642 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/ConnectionChannel.kt @@ -0,0 +1,180 @@ +/* + * 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: NetworkAddress = localPeer + private val pending = ArrayList() + private var idleSinceMillis = clockMillis.asLong + + var fatalError = false + private set + var qport = 0 + private set + + // 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 + 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()) + + /** 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) } + 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: NetworkAddress, qport: Int) { + clear() + destination = 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) + 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. */ + 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, 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 = localPeer + pending.clear() + channel = ReliableChannel(role, 0) + idleSinceMillis = clockMillis.asLong + } +} 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 000000000..ff19e73f5 --- /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/messages/client/ClientMessageCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt new file mode 100644 index 000000000..b00f1fb8f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessageCodec.kt @@ -0,0 +1,59 @@ +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; 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 -> MovementCodec.writeProperties(message, buffer, sequence) + 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 -> MovementCodec.size(message) + else -> error("Unsupported client message ${message.javaClass.name}") + } + + /** 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) { + 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.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. + ClientMessageType.CLC_NOP -> NoopMessage + ClientMessageType.CLC_USERINFO -> UserInfoMessage(buffer.readString()) + ClientMessageType.CLC_STRINGCMD -> StringCmdMessage(buffer.readString()) + 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/ClientMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessages.kt new file mode 100644 index 000000000..04c80552f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/ClientMessages.kt @@ -0,0 +1,27 @@ +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). + * 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/MoveMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/client/MoveMessage.kt new file mode 100644 index 000000000..909cad794 --- /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 000000000..5dfde1c7f --- /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/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 000000000..9072c6049 --- /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/DownloadMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DownloadMessage.kt new file mode 100644 index 000000000..a77c9da7b --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/DownloadMessage.kt @@ -0,0 +1,26 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.network.transport.PacketBytes + +/** Transmits chunks of media (maps, skins, sounds, etc). Chunk length is limited by a signed short (32767 bytes). */ +sealed class DownloadMessage : ServerMessage(ServerMessageType.svc_download) { + data class Chunk(val data: PacketBytes, val percentage: Int) : DownloadMessage() { + init { + require(data.size <= Short.MAX_VALUE) { "Download chunk exceeds signed short length" } + require(percentage in 0..255) { "Download percentage exceeds unsigned byte range" } + } + } + + /** Signed length -1 is failure; retain the legacy -32640 failure marker for network compatibility. */ + data class Failure(val marker: Int = -1, val percentage: Int = 0) : DownloadMessage() { + init { + require(marker == -1 || marker == LEGACY_FAILURE_MARKER) { "Unknown download failure marker" } + require(percentage in 0..255) { "Download percentage exceeds unsigned byte range" } + } + } + + companion object { + const val LEGACY_FAILURE_MARKER = -32640 + @JvmStatic fun chunk(data: ByteArray, percentage: Int): Chunk = Chunk(PacketBytes.copyOf(data), percentage) + } +} 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 000000000..1e49f1e88 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityDeltaCodec.kt @@ -0,0 +1,204 @@ +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 + 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(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()) + 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 = 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) + 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") + 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 + 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 = 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) + if (flags and U_OLDORIGIN != 0) size += 6 + 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 + 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 000000000..f4f4dc14f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/EntityStateSnapshot.kt @@ -0,0 +1,84 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +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" } } + + /** 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/FrameHeaderMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/FrameHeaderMessage.kt new file mode 100644 index 000000000..529be8373 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/FrameHeaderMessage.kt @@ -0,0 +1,22 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.MAX_MAP_AREAS +import jake2.qcommon.network.transport.PacketBytes + +/** + * Generic information about a frame, sent with [PlayerInfoMessage] and [PacketEntitiesMessage] every server frame. + * @property lastFrame what we are delta'ing from. + * @property suppressCount rate dropped packets. + * @property areaBits owned visibility bytes; their size is the transmitted prefix length, without padding. + */ +data class FrameHeaderMessage( + val frameNumber: Int, + val lastFrame: Int, + val suppressCount: Int, + val areaBits: PacketBytes, +) : ServerMessage(ServerMessageType.svc_frame) { + init { require(areaBits.size <= MAX_MAP_AREAS / 8) { "Area bits exceed protocol limit" } } + + constructor(frameNumber: Int, lastFrame: Int, suppressCount: Int, areaBitsLength: Int, areaBits: ByteArray) : + this(frameNumber, lastFrame, suppressCount, PacketBytes.copyOf(areaBits, areaBitsLength)) +} diff --git a/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/InventoryMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/InventoryMessage.kt new file mode 100644 index 000000000..0855cd4f4 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/InventoryMessage.kt @@ -0,0 +1,21 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.MAX_ITEMS +import jake2.qcommon.Defines.CS_ITEMS + +/** + * Inventory sent to client. Holds information about how many specific items a player holds. + * The names of the items are sent via [ConfigStringMessage] starting from [CS_ITEMS] (max 256). + * Owns the first [MAX_ITEMS] counts, represented as signed 16-bit wire values. + */ +class InventoryMessage(inventory: IntArray) : ServerMessage(ServerMessageType.svc_inventory) { + init { require(inventory.size >= 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/PacketEntitiesCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PacketEntitiesCodec.kt new file mode 100644 index 000000000..5c1362475 --- /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 000000000..e1f6748b4 --- /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 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) + 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/PlayerInfoMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerInfoMessage.kt new file mode 100644 index 000000000..56bc8f345 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerInfoMessage.kt @@ -0,0 +1,140 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +import jake2.qcommon.player_state_t +import jake2.qcommon.util.Math3D +import java.util.Collections + +/** + * Selected player-state fields in their quantized wire form. Null means inherit from the delta source; + * an explicit zero remains a transmitted reset. Prediction shorts are bit-precise; view/gun offsets + * and kicks use signed quarter units, view angles use signed angle shorts, and blend uses byte channels. + */ +data class PlayerInfoMessage( + val movementType: Int? = null, + val origin: PlayerDeltaVector? = null, + val velocity: PlayerDeltaVector? = null, + val movementTime: Int? = null, + val movementFlags: Int? = null, + val gravity: Int? = null, + val deltaAngles: PlayerDeltaVector? = null, + val viewOffset: PlayerDeltaVector? = null, + val viewAngles: PlayerDeltaVector? = null, + val kickAngles: PlayerDeltaVector? = null, + val weaponIndex: Int? = null, + val weaponFrame: PlayerWeaponFrame? = null, + val blend: PlayerBlend? = null, + val fov: Int? = null, + val renderFlags: Int? = null, + val stats: PlayerStatDelta = PlayerStatDelta(), + /** Protocol-34's unused high mask bit is retained when decoding/re-encoding. */ + val extraFlags: Int = 0, +) : ServerMessage(ServerMessageType.svc_playerinfo) { + val deltaFlags: Int get() { + var flags = extraFlags + if (movementType != null) flags = flags or PS_M_TYPE + if (origin != null) flags = flags or PS_M_ORIGIN + if (velocity != null) flags = flags or PS_M_VELOCITY + if (movementTime != null) flags = flags or PS_M_TIME + if (movementFlags != null) flags = flags or PS_M_FLAGS + if (gravity != null) flags = flags or PS_M_GRAVITY + if (deltaAngles != null) flags = flags or PS_M_DELTA_ANGLES + if (viewOffset != null) flags = flags or PS_VIEWOFFSET + if (viewAngles != null) flags = flags or PS_VIEWANGLES + if (kickAngles != null) flags = flags or PS_KICKANGLES + if (weaponIndex != null) flags = flags or PS_WEAPONINDEX + if (weaponFrame != null) flags = flags or PS_WEAPONFRAME + if (blend != null) flags = flags or PS_BLEND + if (fov != null) flags = flags or PS_FOV + if (renderFlags != null) flags = flags or PS_RDFLAGS + return flags + } + + init { + require(extraFlags and 0x8000.inv() == 0) + movementType?.let { require(it in 0..255) } + origin?.requireRange(-32768..32767) + velocity?.requireRange(-32768..32767) + movementTime?.let { require(it in 0..255) } + movementFlags?.let { require(it in 0..255) } + gravity?.let { require(it in -32768..32767) } + deltaAngles?.requireRange(-32768..32767) + viewOffset?.requireRange(-128..127) + viewAngles?.requireRange(-32768..32767) + kickAngles?.requireRange(-128..127) + weaponIndex?.let { require(it in 0..255) } + fov?.let { require(it in 0..255) } + renderFlags?.let { require(it in 0..255) } + } + + /** Merge selected movement, view/weapon fields, and changed stats into an already seeded state. */ + fun applyTo(target: player_state_t) { + // Parse the pmove_state_t, preserving bit-accurate prediction values. + movementType?.let { target.pmove.pm_type = it } + origin?.copyShortsTo(target.pmove.origin) + velocity?.copyShortsTo(target.pmove.velocity) + movementTime?.let { target.pmove.pm_time = it.toByte() } + movementFlags?.let { target.pmove.pm_flags = it.toByte() } + gravity?.let { target.pmove.gravity = it.toShort() } + deltaAngles?.copyShortsTo(target.pmove.delta_angles) + // Parse the rest of the player_state_t; unselected values remain inherited. + viewOffset?.copyQuarterUnitsTo(target.viewoffset) + viewAngles?.copyAngleShortsTo(target.viewangles) + kickAngles?.copyQuarterUnitsTo(target.kick_angles) + weaponIndex?.let { target.gunindex = it } + weaponFrame?.let { + target.gunframe = it.frame + it.offset.copyQuarterUnitsTo(target.gunoffset) + it.angles.copyQuarterUnitsTo(target.gunangles) + } + blend?.let { + target.blend[0] = it.red / 255f; target.blend[1] = it.green / 255f + target.blend[2] = it.blue / 255f; target.blend[3] = it.alpha / 255f + } + fov?.let { target.fov = it.toFloat() } + renderFlags?.let { target.rdflags = it } + // Copy only changed stats, including bit 31 of the stat mask. + for (i in 0 until MAX_STATS) stats[i]?.let { target.stats[i] = it.toShort() } + } + + companion object { + /** Determine what needs sending and snapshot it now, without retaining engine state. */ + @JvmStatic fun fromGame(previous: player_state_t, current: player_state_t): PlayerInfoMessage = + PlayerStateCodec.plan(previous, current) + } +} + +/** Three raw wire components; field context determines short, angle-short, or signed quarter-byte units. */ +data class PlayerDeltaVector(val x: Int, val y: Int, val z: Int) { + internal fun requireRange(range: IntRange) { require(x in range && y in range && z in range) } + internal fun copyShortsTo(target: ShortArray) { + target[0] = x.toShort(); target[1] = y.toShort(); target[2] = z.toShort() + } + internal fun copyAngleShortsTo(target: FloatArray) { + target[0] = Math3D.SHORT2ANGLE(x); target[1] = Math3D.SHORT2ANGLE(y); target[2] = Math3D.SHORT2ANGLE(z) + } + internal fun copyQuarterUnitsTo(target: FloatArray) { + target[0] = x * 0.25f; target[1] = y * 0.25f; target[2] = z * 0.25f + } +} + +/** Offsets and angles accompany the weapon frame as a single wire group. */ +data class PlayerWeaponFrame(val frame: Int, val offset: PlayerDeltaVector, val angles: PlayerDeltaVector) { + init { require(frame in 0..255); offset.requireRange(-128..127); angles.requireRange(-128..127) } +} + +/** RGBA full-screen effect channels, retained as bytes to avoid float re-quantization on re-encoding. */ +data class PlayerBlend(val red: Int, val green: Int, val blue: Int, val alpha: Int) { + init { require(red in 0..255 && green in 0..255 && blue in 0..255 && alpha in 0..255) } +} + +/** Owned selected signed-short stats. Missing keys inherit; present zeroes reset. */ +class PlayerStatDelta(values: Map = 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 000000000..b65fc74dd --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/PlayerStateCodec.kt @@ -0,0 +1,114 @@ +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, + // 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, + 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() }), + ) + + // 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()) + 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/ServerControlMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerControlMessages.kt new file mode 100644 index 000000000..9f5d8fcd5 --- /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/ServerDataMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerDataMessage.kt new file mode 100644 index 000000000..466d7866d --- /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 000000000..7d1ecd65c --- /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 new file mode 100644 index 000000000..92d813594 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerMessageCodec.kt @@ -0,0 +1,178 @@ +package jake2.qcommon.network.messages.server + +import jake2.qcommon.Defines.* +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 construct and encode immutable message values. */ +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) } + 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()) } + 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 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 -> { + 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 PlayerInfoMessage -> PlayerStateCodec.write(message, 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 + 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 + 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 PlayerInfoMessage -> 1 + PlayerStateCodec.size(message) + 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 (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_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_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) + ServerMessageType.svc_frame -> readFrameHeader(buffer) + 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") + ServerMessageType.svc_playerinfo -> PlayerStateCodec.read(buffer) + else -> throw MessageReadException(MessageRejection.UNKNOWN_OPCODE, buffer.readcount - 1, "Invalid server opcode $opcode") + } + } + + 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 + } + + 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() + 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/messages/server/ServerTextMessages.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerTextMessages.kt new file mode 100644 index 000000000..e939d1225 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/ServerTextMessages.kt @@ -0,0 +1,23 @@ +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. + */ +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/messages/server/SoundMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SoundMessage.kt new file mode 100644 index 000000000..78d86c1f0 --- /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/messages/server/SpawnBaselineMessage.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/messages/server/SpawnBaselineMessage.kt new file mode 100644 index 000000000..0545f78fc --- /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/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 000000000..ae615bcf2 --- /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 000000000..f19a60ef5 --- /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 000000000..a702af8db --- /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 normal table; select the greatest positive dot product, defaulting to index zero. */ +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/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageBodyCodec.kt new file mode 100644 index 000000000..b8c14b33d --- /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, 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 } + + 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 000000000..a3603d6b7 --- /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 } + +/** 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 new file mode 100644 index 000000000..639e05811 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/MessageReader.kt @@ -0,0 +1,82 @@ +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 + +/** 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) + data = bytes.toByteArray() + cursize = bytes.size + readcount = offset + } + + companion object { + /** 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 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) + throw MessageReadException(MessageRejection.TRUNCATED, buffer.readcount, "Need $length bytes") + } + } + + override fun readByte(): Int { + 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 + + /** 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 } + } + + /** 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() + } + 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/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt new file mode 100644 index 000000000..b5390efc6 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/PacketCodec.kt @@ -0,0 +1,67 @@ +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 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 + 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) + 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 000000000..968215cc4 --- /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/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt new file mode 100644 index 000000000..70e9e239e --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/protocol/ReliableChannel.kt @@ -0,0 +1,234 @@ +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. + * + * 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, + 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/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt new file mode 100644 index 000000000..1cf1c2656 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/NetworkTransport.kt @@ -0,0 +1,99 @@ +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 { + 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 + 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 + } + + /** Discard this owner's incoming local packets without dropping the peer's final messages. */ + 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/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt new file mode 100644 index 000000000..de0021c24 --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/runtime/PacketNetwork.kt @@ -0,0 +1,134 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Com +import jake2.qcommon.Defines +import jake2.qcommon.network.NetworkSettings +import jake2.qcommon.network.ConnectionChannel +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 @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. */ + 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 + + 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( + if (role == NetworkRole.CLIENT) ChannelRole.CLIENT else ChannelRole.SERVER, + transport.localPeer, transport::send, + logPackets = { settings.logPackets }, + log = log, + clockMillis = { System.nanoTime() / 1_000_000L }, + ) + } + + /** + * 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) { + transport.replaceUdp(null) + return true + } + if (transport.udpEndpoint?.isOpen == true) return true + 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) + 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) { + log("$label UDP bind error: $error\n") + false + } + } + + /** 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()) + } + + /** 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) 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 (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 && settings.logMessages) { + val peer = if (role == NetworkRole.CLIENT) "server" else "client" + log("Network: Received from $peer connectionless: ${packet.connectionlessMessage}\n") + } + return packet + } + + /** Out-of-band datagram: a -1 sequence marker followed by Latin-1 command/payload bytes. */ + 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 result = transport.send(to, PacketBytes.copyOf(bytes)) + 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. */ + fun stop() { + if (closed) return + try { + transport.replaceUdp(null) + } finally { + transport.clearLocalIncoming() + } + } + + /** Shutdown closes this owner's UDP and local endpoints, independently of the peer's resources. */ + override fun close() { + if (closed) return + closed = true + transport.close() + } +} 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 000000000..7d9dfb83a --- /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/DatagramEndpoint.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/DatagramEndpoint.kt new file mode 100644 index 000000000..4c446aa32 --- /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 000000000..eed02c54c --- /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 000000000..0b1136fed --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/NetworkAddress.kt @@ -0,0 +1,64 @@ +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) + } +} + +/** + * 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 { + @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 000000000..176838d0f --- /dev/null +++ b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/PacketBytes.kt @@ -0,0 +1,32 @@ +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() + /** 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) + 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)) + } + + @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/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt b/qcommon/src/main/kotlin/jake2/qcommon/network/transport/UdpDatagramEndpoint.kt new file mode 100644 index 000000000..0bcf7bd81 --- /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/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 000000000..91b63f60b --- /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/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java b/qcommon/src/test/java/jake2/qcommon/CommonCvarsTest.java index 876146027..fb4b11b36 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/main/java/jake2/qcommon/DeltaUtils.java b/qcommon/src/test/java/jake2/qcommon/DeltaUtils.java similarity index 83% rename from qcommon/src/main/java/jake2/qcommon/DeltaUtils.java rename to qcommon/src/test/java/jake2/qcommon/DeltaUtils.java index 3a8ef16d0..9513eaff1 100644 --- a/qcommon/src/main/java/jake2/qcommon/DeltaUtils.java +++ b/qcommon/src/test/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/test/java/jake2/qcommon/network/NetPacketReceiveTest.java b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java new file mode 100644 index 000000000..907b5f92d --- /dev/null +++ b/qcommon/src/test/java/jake2/qcommon/network/NetPacketReceiveTest.java @@ -0,0 +1,140 @@ +package jake2.qcommon.network; + +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 jake2.qcommon.network.transport.SendResult; +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 java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.DatagramChannel; +import java.util.Arrays; +import java.util.concurrent.locks.LockSupport; + +import static jake2.qcommon.Defines.*; +import static org.junit.jupiter.api.Assertions.*; + +class NetPacketReceiveTest { + private OwnedNetworkPair pair; + @BeforeEach + void setup() { + Cvar.getInstance().clear(); + NetworkSettings.registerCvars(); + pair = new OwnedNetworkPair(); + } + + @AfterEach + void cleanup() { + pair.close(); + 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); + assertInstanceOf(NetworkAddress.Udp.class, packet.from); + } + + @Test + void socketPacketAcceptsAnExactlySizedArray() { + byte[] bytes = sequencedPacket(MAX_MSGLEN, true); + 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); + 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); + + 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() { + assertSame(SendResult.Sent.INSTANCE, pair.clientTransport.send( + pair.clientTransport.getLocalPeer(), PacketBytes.copyOf(sequencedPacket(8, false)))); + pair.client.sendConnectionless(pair.client.getLocalPeer(), ConnectionlessCommand.getchallenge, "\n"); + + assertNull(pair.server.receive()); + NetworkPacket next = pair.server.receive(); + assertNotNull(next); + assertEquals("getchallenge", next.connectionlessMessage); + } + + @Test + void serverPrintReplySeparatesCommandFromMultilineParameters() { + pair.server.sendConnectionless(pair.server.getLocalPeer(), ConnectionlessCommand.print, + "\nFirst line\nSecond line\n"); + + NetworkPacket packet = pair.client.receive(); + 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); + ByteBuffer header = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + header.putInt(7).putInt(3); + if (fromClient) header.putShort((short) 42); + return bytes; + } + + 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(network.configure(true)); + try (var sender = DatagramChannel.open()) { + 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 = network.receive(); + if (packet != null) return packet; + LockSupport.parkNanos(1_000_000); + } + 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 50e2b215c..c451c55a3 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetSocketOwnershipTest.java @@ -6,68 +6,96 @@ 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.*; class NetSocketOwnershipTest { + private OwnedNetworkPair pair; @BeforeEach void setup() { - NET.Config(false); - NET.clearLoopbacks(); 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); } @AfterEach void cleanup() { - NET.Config(false); - NET.clearLoopbacks(); + pair.close(); Cvar.getInstance().clear(); } @Test void clientTeardownPreservesServerAndServerTeardownPreservesClient() { - assertTrue(NET.configureServer(true)); - var server = NET.ip_sockets[NS_SERVER]; - assertNull(NET.ip_sockets[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]); - assertTrue(NET.configureClient(true)); - var client = NET.ip_sockets[NS_CLIENT]; - NET.configureServer(false); - assertSame(client, NET.ip_sockets[NS_CLIENT]); - assertFalse(client.isClosed()); - assertNull(NET.ip_channels[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(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(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.ip_sockets[NS_SERVER]); - assertNull(NET.ip_channels[NS_SERVER]); + assertFalse(pair.server.configure(true)); + assertNull(pair.serverTransport.getUdpEndpoint()); + } + assertTrue(pair.server.configure(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(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 + 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(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(pair.clientTransport.getUdpEndpoint().isOpen()); } - assertTrue(NET.configureServer(true)); } @Test void loopbackWorksWithoutSocketsAndSessionResetDiscardsBothQueues() { - Netchan.sendConnectionlessPacket(NS_CLIENT, new netadr_t(), ConnectionlessCommand.getchallenge, "\n"); - assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[NS_SERVER], true)); - 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)); + pair.client.sendConnectionless(pair.client.getLocalPeer(), ConnectionlessCommand.getchallenge, "\n"); + assertNotNull(pair.server.receive()); + 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 160d76da4..58a7368e8 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/NetchanTest.java @@ -1,56 +1,137 @@ 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; +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; +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; 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 { + private NetworkTransport client; + private NetworkTransport server; + private PacketNetwork serverPackets; @BeforeEach 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, new CvarNetworkSettings(Cvar.getInstance())); + Cvar.getInstance().clear(); Globals.curtime = 0; - Netchan.Netchan_Init(); + NetworkSettings.registerCvars(); + } + + @AfterEach + void cleanup() throws Exception { + try { client.close(); } finally { server.close(); } + Cvar.getInstance().clear(); + } + + @Test + void outgoingClientPacketUsesItsSetupQport() { + 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)); + assertEquals(1234, serverPackets.receive().qport); + } + + @Test + 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(channel.getRemoteAddress(), 7); + channel.queueReliable(new StringCmdMessage("new")); + Globals.curtime = 100; + assertFalse(channel.transmit(null)); + assertEquals(1, channel.getOutgoingSequence()); + assertEquals(1, channel.getQueuedReliable().size()); + assertEquals(0, channel.getLastSentSize()); + assertEquals(0, channel.getLastSentMillis()); + assertEquals(0, channel.getReliableSentFlag()); + + available[0] = true; + assertTrue(channel.transmit(null)); + assertEquals(2, channel.getOutgoingSequence()); + assertTrue(channel.getQueuedReliable().isEmpty()); + assertEquals(100, channel.getLastSentMillis()); + } + + @Test + void unexpectedSendFailureReleasesPreparedTransaction() { + 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(channel.getRemoteAddress(), 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(); - netchan.setup(NS_SERVER, new netadr_t(), 0); + ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); + netchan.setup(netchan.getRemoteAddress(), 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(); - netchan.setup(NS_SERVER, new netadr_t(), 0); + ConnectionChannel netchan = new ConnectionChannel(ChannelRole.SERVER, server.getLocalPeer(), server::send, () -> (long) Globals.curtime); + netchan.setup(netchan.getRemoteAddress(), 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) { - sizebuf_t buffer = new sizebuf_t(8); + sizebuf_t buffer = new sizebuf_t(10); buffer.writeInt(sequence); buffer.writeInt(sequenceAck); - return NetworkPacket.fromLoopback(false, buffer.data, buffer.cursize); + buffer.writeShort(0); + var decoded = (PacketDecodeResult.Decoded) PacketCodec.decode( + 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/java/jake2/qcommon/network/OwnedNetworkPair.java b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java new file mode 100644 index 000000000..9ddaeaf3d --- /dev/null +++ b/qcommon/src/test/java/jake2/qcommon/network/OwnedNetworkPair.java @@ -0,0 +1,27 @@ +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; +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, new CvarNetworkSettings(Cvar.getInstance())); + final PacketNetwork server = new PacketNetwork(serverTransport, NetworkRole.SERVER, new CvarNetworkSettings(Cvar.getInstance())); + + void clearLocalTraffic() { + link.getFirst().clearIncoming(); + link.getSecond().clearIncoming(); + } + + @Override + public void close() { + try { client.close(); } finally { server.close(); } + } +} 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 ea426382c..bdd0e3f10 100644 --- a/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java +++ b/qcommon/src/test/java/jake2/qcommon/network/messages/NetworkMessageSizeTest.java @@ -39,18 +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.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")}); + 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.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])}); testMessages.add(new Object[]{"server.MuzzleFlash2Message", new MuzzleFlash2Message(1, 2)}); @@ -65,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); @@ -113,57 +114,58 @@ 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)}); - PacketEntitiesMessage packetEntitiesMessage = new PacketEntitiesMessage(); + testMessages.add(new Object[]{"server.PlayerInfoMessage", PlayerInfoMessage.fromGame(new player_state_t(), currentState)}); + 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 ////////////////// - 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")}); // 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) @@ -174,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) @@ -183,10 +185,15 @@ public void testSerializationDeserializationEquality(String testName, NetworkMes else parsed = ClientMessage.parseFromBuffer(buffer, 1); - if (message instanceof NopMessage - || message instanceof EndOfServerPacketMessage - || message instanceof EndOfClientPacketMessage - || message instanceof NoopMessage) { + 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"); @@ -194,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"); @@ -219,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/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 8104ac433..85d025a78 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 ServerMessage { +/** 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/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt index 29b52246b..0851710ff 100644 --- a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -1,13 +1,14 @@ package jake2.qcommon -import jake2.qcommon.network.NET -import jake2.qcommon.network.NetAddrType -import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.CvarNetworkSettings +import jake2.qcommon.network.runtime.* +import jake2.qcommon.network.transport.LocalSide +import jake2.qcommon.network.messages.ConnectionlessCommand +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 java.net.DatagramSocket +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.NetworkSettings class OutputFailureTest { @Test @@ -41,22 +42,25 @@ 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) { - val previous = NET.ip_sockets[Defines.NS_SERVER] - DatagramSocket(0).use { socket -> - NET.ip_sockets[Defines.NS_SERVER] = socket - try { + @Test + fun `oversized connectionless output propagates without closing the socket`() { + val cvars = Cvar.getInstance() + cvars.clear() + NetworkSettings.registerCvars() + cvars.find("port", Int::class.javaObjectType).forceSet(0) + val transport = NetworkTransport.standalone(LocalSide.SECOND) + try { + PacketNetwork(transport, NetworkRole.SERVER, CvarNetworkSettings(Cvar.getInstance())).use { packets -> + assertTrue(packets.configure(true)) + val endpoint = transport.udpEndpoint!! val failure = assertThrows(IllegalArgumentException::class.java) { - NET.SendPacket(Defines.NS_SERVER, 1, byteArrayOf(1), netadr_t().apply { this.type = type }) + packets.sendConnectionless(packets.localPeer, ConnectionlessCommand.print, "x".repeat(Defines.MAX_MSGLEN)) } - 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 + assertSame(endpoint, transport.udpEndpoint) + assertTrue(endpoint.isOpen) } + } finally { + cvars.clear() } } } 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 000000000..0770291de --- /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.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, 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).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).checksumValid) + 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) + } + } +} 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 000000000..0033584d8 --- /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) + } + } +} 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 000000000..681f8af90 --- /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, 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, 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, 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, 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, sequence), "$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 + 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()) + 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/MessageReencodingTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt new file mode 100644 index 000000000..111a68352 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MessageReencodingTest.kt @@ -0,0 +1,127 @@ +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 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) + } + } + } + + @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 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 + add(EntityUpdate.Removal(flags, index)) + } + val message = PacketEntitiesMessage(entries) + roundTrip(message) + val parsed = parse(write(message)) as PacketEntitiesMessage + 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 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() 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), + 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..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.channel) + assertEquals(flags, decoded.flags) + assertEquals(message, decoded) + roundTrip(message) + } + } + + @Test + 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()) + val edited = decoded.copy(timeOffsetMillis = 123) + assertEquals(123.toByte(), write(edited).last()) + assertEquals(251.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") + } + } +} 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 000000000..a97ccce03 --- /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.command(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/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/MovementCodecTest.kt new file mode 100644 index 000000000..350f09982 --- /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/OwnedMessagePayloadTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/OwnedMessagePayloadTest.kt new file mode 100644 index 000000000..a5e136a7f --- /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/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 000000000..8403bc3c4 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PacketCodecTest.kt @@ -0,0 +1,159 @@ +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()) + 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) + 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 as NetworkAddress.Udp).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.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, 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.checksumValid) + assertEquals(12, parsed.lastReceivedFrame) + assertEquals(120, parsed.newCmd.forwardmove) + assertEquals(16, parsed.newCmd.msec) + } + + 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 +} 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 000000000..b98441cc4 --- /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/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 000000000..9ae302120 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/protocol/PlayerStateCodecTest.kt @@ -0,0 +1,177 @@ +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) + // 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))) + } + } + // 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(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 + 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/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 000000000..62ceab3ae --- /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.fromInput(false, 3, usercmd_t(), usercmd_t(), usercmd_t()) + val buffer = sizebuf_t(capacity) + 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).checksumValid) + } + + 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/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 000000000..83da9c403 --- /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/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 000000000..c8c195175 --- /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/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 000000000..33c5c9507 --- /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/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 000000000..dc3a46217 --- /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) } + } +} 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 000000000..1b995df4a --- /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 new file mode 100644 index 000000000..f68de6118 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/ConnectionChannelTest.kt @@ -0,0 +1,133 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +import jake2.qcommon.network.ConnectionChannel +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(channel.remoteAddress, 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 = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(10, 1, 2, 3)), 27910) + channel.setup(address, 1234) + address.ip.toByteArray().fill(99) + val view = channel.remoteAddress + (view as NetworkAddress.Udp).ip.toByteArray().fill(88) + 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(channel.remoteAddress, 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(channel.remoteAddress, 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(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/NetworkSettingsTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkSettingsTest.kt new file mode 100644 index 000000000..cd6bdd7ef --- /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/NetworkTransportTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt new file mode 100644 index 000000000..298156590 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/NetworkTransportTest.kt @@ -0,0 +1,102 @@ +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) + } + + @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 + 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) + } } } } + } +} 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 000000000..1c5b85389 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/OwnedChannelTest.kt @@ -0,0 +1,95 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.Defines +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 +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.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.incomingSequence) + val body = packet.parseBodyFromClient(session.server.incomingSequence) 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) + val serverTransport = NetworkTransport(link.second, link.first.localAddress) + val client = ConnectionChannel(ChannelRole.CLIENT, clientTransport.localPeer, clientTransport::send) { 100L } + val server = ConnectionChannel(ChannelRole.SERVER, serverTransport.localPeer, serverTransport::send) { 100L } + + init { + client.setup(client.remoteAddress, 42) + server.setup(server.remoteAddress, 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.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) + val secondPacket = second.receive(second.serverTransport, PacketDirection.FROM_CLIENT) + assertTrue(first.server.accept(firstPacket)) + assertTrue(second.server.accept(secondPacket)) + 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.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.canSendReliable) + } } + } + + @Test + fun `clearing a channel retains its borrowed transport and failed send retains staging`() { + Session().use { session -> + session.client.clear() + 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() + session.client.queueReliable(StringCmdMessage("retry")) + assertFalse(session.client.transmit(null)) + 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 new file mode 100644 index 000000000..fc32cbe91 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/network/runtime/PacketNetworkTest.kt @@ -0,0 +1,128 @@ +package jake2.qcommon.network.runtime + +import jake2.qcommon.network.CvarNetworkSettings +import jake2.qcommon.Defines +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.NetworkSettings +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, 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) + 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, 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) + 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 -> + val clientTransport = NetworkTransport(link.first, link.second.localAddress) + val serverTransport = NetworkTransport(link.second, link.first.localAddress) + 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)) + val request = server.receive()!! + assertEquals(1234, request.qport) + assertEquals(10, request.buffer.readcount) + + val serverChannel = server.newChannel() + serverChannel.setup(serverChannel.remoteAddress, 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(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(server.localPeer, 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() + NetworkSettings.registerCvars() + val transport = NetworkTransport.standalone(LocalSide.SECOND) + try { + 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)) + 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/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 000000000..e18195a29 --- /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/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 000000000..c429e1d67 Binary files /dev/null and b/qcommon/src/test/resources/jake2/qcommon/network/messages/client.StringCmdMessage.highbit.bin differ 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 000000000..78432a22e Binary files /dev/null and b/qcommon/src/test/resources/jake2/qcommon/network/messages/server.PrintMessage.highbit.bin differ diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index d4eff93b9..c1ba9ef55 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,8 @@ of the License, or (at your option) any later version. */ public class ClientNetworkInfo { - public ClientNetworkInfo() { + 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(); } @@ -90,12 +91,12 @@ public ClientNetworkInfo() { int challenge; // challenge of this user, randomly generated - netchan_t netchan = new netchan_t(); + 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 316e3fda9..23ff7f8e6 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; @@ -450,11 +449,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 +467,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); } /* @@ -848,7 +843,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"; @@ -899,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(new StuffTextMessage(s)); + client.netchan.queueReliable(StuffTextMessage.command(s)); } } @@ -1220,7 +1215,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) { @@ -1325,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); } @@ -1346,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); @@ -1395,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 @@ -1415,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; @@ -1453,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) { @@ -1468,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); } /** 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 70c05c0ee..000000000 --- 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/java/jake2/server/save/ServerSaveFiles.java b/server/src/main/java/jake2/server/save/ServerSaveFiles.java index 8b814e0fc..f9fbdd5c5 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/main/java/jake2/server/save/ServerSaveJsonStore.java b/server/src/main/java/jake2/server/save/ServerSaveJsonStore.java index 222c67ee8..f2f95a399 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/ServerConnections.kt b/server/src/main/kotlin/jake2/server/ServerConnections.kt new file mode 100644 index 000000000..b3051c460 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerConnections.kt @@ -0,0 +1,100 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.transport.NetworkAddress +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 sealed interface PeerKey { + data class Remote(val ip: IpAddress) : PeerKey + data class Local(val address: NetworkAddress.Local) : PeerKey + companion object { + 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 + 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: 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: 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: 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 && !samePort(client.netchan.remoteAddress, from)) continue + if (from !is NetworkAddress.Local && 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.remoteAddress) == key + } ?: return null + if (!client.netchan.accept(packet)) return null + val changed = !samePort(client.netchan.remoteAddress, packet.from) + if (changed) client.netchan.updateRemotePort((packet.from as NetworkAddress.Udp).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 7d3677b72..d23301720 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -24,14 +24,12 @@ 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.protocol.MessageDecodeResult 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 @@ -39,7 +37,10 @@ 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 network: ServerNetwork, + private val startupRejection: () -> String? = { null }, +) : AutoCloseable { // region Host state and command dispatch private val commands = ServerCommands() @@ -50,8 +51,7 @@ class ServerHost : AutoCloseable { @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 @@ -239,7 +239,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") { @@ -294,8 +295,10 @@ class ServerHost : AutoCloseable { /** 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) + } } } @@ -316,13 +319,10 @@ class ServerHost : AutoCloseable { 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() - NET.configureServer(false) - // Keep outgoing final messages available to the local client. - NET.clearLoopback(Defines.NS_SERVER) + network.stop() } } @@ -353,7 +353,7 @@ class ServerHost : AutoCloseable { client.gameName = null client.edict = null client.download = null - client.netchan.reliablePending.clear() + client.netchan.clearQueuedReliable() client.unreliable.clear() } pendingContinuations.remove(game) @@ -370,7 +370,7 @@ class ServerHost : AutoCloseable { // Clear all clients clients = MutableList(max) { - ClientNetworkInfo().apply { lastcmd = usercmd_t() } + ClientNetworkInfo(network.newChannel()).apply { lastcmd = usercmd_t() } } } @@ -389,7 +389,7 @@ class ServerHost : AutoCloseable { 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() } @@ -481,6 +481,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 +564,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" } @@ -639,9 +641,9 @@ 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") + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "autosave") } // multi instance preview: @@ -680,7 +682,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.queueReliable(StuffTextMessage.command("changing")) } } @@ -701,7 +703,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.queueReliable(StuffTextMessage.command("reconnect")) } } return gameImports @@ -820,7 +822,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 } @@ -967,56 +969,14 @@ 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) { + private fun connectClient(args: List, adr: NetworkAddress) { Com.DPrintf("SVC_DirectConnect ()\n") 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") @@ -1030,76 +990,35 @@ 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 - - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, - ConnectionlessCommand.print, - "\nBad challenge.\n" - ) - return - } - j++ - } - if (j == Defines.MAX_CHALLENGES) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - 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) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n") - Com.DPrintf("Rejected a connection.\n") - return } - initializeClientConnection(index, challenge, userinfo, adr, qport) } /** * 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 @@ -1118,16 +1037,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" ) @@ -1140,9 +1055,9 @@ 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) + client.netchan.setup(adr, qport) client.state = ClientStates.CS_CONNECTED @@ -1156,7 +1071,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()) { @@ -1169,34 +1084,28 @@ 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 ) } ConnectionlessCommand.getchallenge -> { - val challenge = getChallenge(from) - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, + val challenge = connections.issueChallenge(from) + network.sendConnectionless(from, ConnectionlessCommand.challenge, " $challenge" ) @@ -1216,7 +1125,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) } @@ -1240,42 +1149,30 @@ 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 = NET.receiveNetworkPacket( - NET.ip_sockets[Defines.NS_SERVER], - NET.ip_channels[Defines.NS_SERVER], - NET.loopbacks[Defines.NS_SERVER], - true - ) - - 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. - // 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) + 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.incomingSequence) + when (body) { + is MessageDecodeResult.Rejected -> { + Com.Printf("Bad client payload at ${body.offset}: ${body.detail}\n") + dropClient(client) + } + is MessageDecodeResult.Decoded -> { + if (network.logMessages) body.messages.forEach { message -> + if (message !is MoveMessage) Com.Printf("Network: Received client msg: $message\n") + } + handleClientMessages(client, body.messages) } } - break } } } @@ -1327,7 +1224,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 } @@ -1351,8 +1248,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) @@ -1361,6 +1258,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 @@ -1448,9 +1347,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.edict.index - 1 } - client.netchan.reliablePending.add( - ServerDataMessage( - Defines.PROTOCOL_VERSION, + client.netchan.queueReliable( + ServerDataMessage.forClient( gameImports.spawncount, gameImports.isDemo, gamedir, @@ -1469,8 +1367,8 @@ 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( - StuffTextMessage( + client.netchan.queueReliable( + StuffTextMessage.command( String.format( "cmd %s %d 0", StringCmdMessage.CONFIG_STRINGS, @@ -1505,12 +1403,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++ } @@ -1521,7 +1419,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.queueReliable(StuffTextMessage.command(nextCmd)) } private fun sendBaselines(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1545,13 +1443,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++ } @@ -1564,7 +1462,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.queueReliable(StuffTextMessage.command(nextCmd)) } private fun beginClient(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { @@ -1614,9 +1512,9 @@ 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") + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "autosave") } OperationResult.Completed } catch (failure: Exception) { @@ -1656,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.queueReliable(DownloadMessage.chunk(data, percent)) if (client.downloadcount == client.downloadsize) { client.download = null @@ -1701,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.queueReliable(DownloadMessage.Failure()) return } @@ -1710,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.queueReliable(DownloadMessage.Failure()) return } @@ -1727,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.queueReliable(DownloadMessage.Failure()) return } @@ -1749,9 +1647,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) @@ -1767,7 +1665,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 ) } @@ -1787,7 +1685,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.queueReliable(PrintMessage(level, message.orEmpty())) } } @@ -1801,7 +1699,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 is NetworkAddress.Local) return false var total = 0 @@ -1830,10 +1728,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 @@ -1859,12 +1757,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 @@ -1915,7 +1813,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") @@ -2014,35 +1912,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: NetworkAddress) { + 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 000000000..37dccef31 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerNetwork.kt @@ -0,0 +1,57 @@ +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 +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 { + val logMessages: Boolean + 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: NetworkAddress, command: ConnectionlessCommand, payload: String) + fun stop() + fun sleep(millis: Int) + override fun close() = stop() +} + +/** Owns the supplied transport; connections borrow it through the shared packet adapter. */ +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, 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) + override fun poll(budget: Int, consume: (NetworkPacket) -> Unit) = packets.poll(budget, consume) + override fun sendConnectionless(to: NetworkAddress, command: ConnectionlessCommand, payload: String) = + packets.sendConnectionless(to, command, payload) + 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 { + Thread.sleep(millis.toLong()) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } +} 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 000000000..32ca7c465 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerSaveCatalog.kt @@ -0,0 +1,95 @@ +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 + +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?, + val mapTitle: String? = null, +) + +/** 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 + var mapTitle: String? = 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 + 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" + } + 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, 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/main/kotlin/jake2/server/save/SaveSlotDeletion.kt b/server/src/main/kotlin/jake2/server/save/SaveSlotDeletion.kt new file mode 100644 index 000000000..991c29669 --- /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/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 000000000..0cca526f5 --- /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 + } + ] +} diff --git a/server/src/test/java/jake2/server/AirAccelerationTest.java b/server/src/test/java/jake2/server/AirAccelerationTest.java index f19fd19c6..1f78dc38b 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 a7851fd16..4c8bb8109 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/java/jake2/server/save/ServerSaveFilesTest.java b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java index e16278335..6dbb7916b 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 @@ -62,9 +66,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 @@ -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 { diff --git a/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java b/server/src/test/java/jake2/server/save/ServerSaveJsonStoreTest.java index e2075f725..9c89b3602 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/GameModeCvarsTest.kt b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt index 3a6f3ebc8..094171b77 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 8793b6d99..6876fd535 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) } 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 000000000..606a82ee6 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerConnectionsTest.kt @@ -0,0 +1,127 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.network.ConnectionChannel +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) = NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(192.toByte(), 0, 2, host.toByte())), 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.toByteArray()[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(LoopbackLink().first.localAddress, 123)) + assertEquals(ServerConnections.ChallengeValidation.MISSING, + connections.validateChallenge(NetworkAddress.Udp(IpAddress.fromBytes(byteArrayOf(127, 0, 0, 1)), 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(ConnectionChannel(ChannelRole.SERVER, transport.localPeer, transport::send) { 0L }).apply { + this.state = state + netchan.setup(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(transport.localPeer, 7) + assertEquals(ServerConnections.Admission.Slot(1, true), + connections.selectSlot(clients, transport.localPeer, 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(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 + fun `routing rejects wrong peers and stale packets before committing a translated port`() { + NetworkTransport.standalone(LocalSide.SECOND).use { transport -> + val client = ClientNetworkInfo(ConnectionChannel(ChannelRole.SERVER, transport.localPeer, transport::send) { 0L }).apply { + state = ClientStates.CS_CONNECTED + 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.remoteAddress as NetworkAddress.Udp).port) + assertEquals(ServerConnections.Routed(client, true), connections.route(packet(2, 20000), clients)) + 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 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 as NetworkAddress.Udp).port) + } + } +} 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 000000000..7b72c1e61 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerNetworkTest.kt @@ -0,0 +1,128 @@ +package jake2.server + +import jake2.qcommon.MainCommon +import jake2.qcommon.exec.Cvar +import jake2.qcommon.network.NetworkSettings +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.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 + +class ServerNetworkTest { + 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 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) + } + override fun close() { + host.close() + client.close() + } + } + + @Test + fun `malformed client body drops only that client without dispatching its valid prefix`() { + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + NetworkSettings.registerCvars() + try { + Peer().use { peer -> + fun connection(qport: Int) = ClientNetworkInfo(peer.network.newChannel()).apply { + state = ClientStates.CS_CONNECTED + userinfo = "before" + netchan.setup(netchan.remoteAddress, 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.queuedReliable.any { it is DisconnectMessage }) + assertEquals(ClientStates.CS_CONNECTED, healthy.state) + assertTrue(healthy.netchan.queuedReliable.isEmpty()) + } + } finally { + Cvar.getInstance().clear() + } + } + + @Test + fun `Host packet pass bounds malformed and valid traffic and resumes next pass`() { + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + NetworkSettings.registerCvars() + 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() + MainCommon.registerCvars(false) + NetworkSettings.registerCvars() + 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() + } + } +} 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 000000000..233f07a55 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/ServerSaveCatalogTest.kt @@ -0,0 +1,97 @@ +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", "Test Box") + 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("autosave", 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("autosave", "coop", "deathmatch", "console checkpoint"), + entries.filter { it.problem == null }.map { it.slot }) + 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) + assertNotNull(entries.first { it.slot == "incomplete" }.problem) + } + + @Test + fun `existing saves get map titles from level archives or autosave comments`() { + metadata("manual", 1) + metadata("autosave", 2) + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + store.writeMapCommand("manual", "old checkpoint comment", "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("autosave").mapTitle) + } + + @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() } + } +} 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 000000000..94d9c18b4 --- /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"))) + } +}