diff --git a/README.md b/README.md index b7399d4d5..37f60debc 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ build from source: - `./gradlew :cake:cake-fullgame:run` - to run Cake with singleplayer and hosting support - `./gradlew :cake:cake-client:distZip` - to build the desktop Java distribution - `./gradlew :cake:cake-fullgame:distZip` - to build the full game desktop Java distribution -- `./gradlew :cake:cake-modelviewer:distZip` - to build the model viewer desktop Java distribution +- `./gradlew :cake:cake-viewer:distZip` - to build the viewer desktop Java distribution If you run jake from an IDE: 1. to run the server run `Jake2Dedicated.main` with the desired args (`+set coop 1 +map base1`) diff --git a/cake/README.md b/cake/README.md index 6c6b08238..dd0d32223 100644 --- a/cake/README.md +++ b/cake/README.md @@ -21,12 +21,16 @@ and Host Game menus. Core accesses hosting through `CakeHost`; only ### `desktop-common` Shared game window configuration, video platform, JVM restart helper and desktop -icons. Both game launchers use `CakeDesktopLauncher`; the model viewer shares +icons. Both game launchers use `CakeDesktopLauncher`; the viewer shares the restart helper and icons while retaining its own window configuration. -### `cake-modelviewer` +The icons in `desktop-common/icons` and `desktop-common/src/main/resources` are +the default libGDX Liftoff placeholders, pending Cake branding. -Standalone desktop launcher for the Cake model viewer. +### `cake-viewer` + +Standalone desktop launcher for the Cake Viewer. +Opens MD2 models, BSP maps, and PCX/WAL images; see the [viewer guide](../info/cake-viewer.md). ### [`engine-tools`](engine-tools/README.md) @@ -40,7 +44,7 @@ From repository root: - `./gradlew :cake:cake-client:run` - runs the remote-only client. - `./gradlew :cake:cake-fullgame:run` - runs the client with singleplayer and hosting support. -- `./gradlew :cake:cake-modelviewer:run --args="/absolute/path/to/file.md2"` - runs the model viewer launcher directly. +- `./gradlew :cake:cake-viewer:run --args="/absolute/path/to/file.md2"` - runs the viewer launcher directly. Main desktop entrypoint: @@ -51,17 +55,17 @@ Main desktop entrypoint: Build portable ZIPs with Java 25: ```sh -./gradlew :cake:cake-client:distZip :cake:cake-modelviewer:distZip +./gradlew :cake:cake-client:distZip :cake:cake-viewer:distZip ./gradlew :cake:cake-fullgame:distZip ``` Each launcher's `build/distributions/` contains a ZIP with `bin/` launch scripts and `lib/` application/dependency JARs. Extract the entire ZIP and run -`bin/cake-client`, `bin/cake-fullgame`, or `bin/cake-modelviewer` (the `.bat` script on Windows). -Pass an asset path to the model viewer; Quake 2 game data remains external. +`bin/cake-client`, `bin/cake-fullgame`, or `bin/cake-viewer` (the `.bat` script on Windows). +Pass an asset path to the viewer; Quake 2 game data remains external. For a local installation, use `:cake:cake-client:installDist` or -`:cake:cake-modelviewer:installDist`; scripts are under the corresponding +`:cake:cake-viewer:installDist`; scripts are under the corresponding `build/install//bin/` directory. JARs under `build/libs/` are normal project JARs and need the distribution classpath. Use `distZip` instead of the former custom `dist` task; standalone fat JARs are no longer produced. @@ -80,7 +84,7 @@ tasks. Beryx 2.0.1 still requires `--no-configuration-cache` for ``` `runtimeZip` writes `build/image.zip`; `jpackageImage` writes an application -under `build/jpackage/`. Use the corresponding model-viewer tasks for that app. +under `build/jpackage/`. Use the corresponding viewer tasks for that app. These outputs bundle the project Java toolchain and target the build machine's OS and architecture. Installers remain disabled. @@ -91,15 +95,17 @@ experimental native executable: ```sh ./gradlew -PenableGraalNative=true :cake:cake-client:nativeCompile -./gradlew -PenableGraalNative=true :cake:cake-modelviewer:nativeCompile +./gradlew -PenableGraalNative=true :cake:cake-viewer:nativeCompile ``` These tasks require GraalVM 25 or newer with Native Image. Set `GRAALVM_HOME` to 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. -Fullgame native-image support is not verified; embedded game reflection and save -serialization need additional reachability coverage before that is supported. +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. ## Playing and hosting diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index 3433403b9..0ced98f3f 100644 --- a/cake/cake-fullgame/build.gradle.kts +++ b/cake/cake-fullgame/build.gradle.kts @@ -12,7 +12,6 @@ apply(from = rootProject.file("cake/desktop.gradle")) dependencies { implementation(project(":cake:core")) - implementation(project(":cake:desktop-common")) implementation(project(":server")) implementation(project(":qcommon")) runtimeOnly(project(":game")) diff --git a/cake/cake-lwjgl3/icons/logo.icns b/cake/cake-lwjgl3/icons/logo.icns deleted file mode 100644 index 5e41ad7c1..000000000 Binary files a/cake/cake-lwjgl3/icons/logo.icns and /dev/null differ diff --git a/cake/cake-lwjgl3/icons/logo.ico b/cake/cake-lwjgl3/icons/logo.ico deleted file mode 100644 index c4f2d5ed4..000000000 Binary files a/cake/cake-lwjgl3/icons/logo.ico and /dev/null differ diff --git a/cake/cake-lwjgl3/icons/logo.png b/cake/cake-lwjgl3/icons/logo.png deleted file mode 100644 index 788f542c3..000000000 Binary files a/cake/cake-lwjgl3/icons/logo.png and /dev/null differ diff --git a/cake/cake-lwjgl3/src/main/resources/libgdx128.png b/cake/cake-lwjgl3/src/main/resources/libgdx128.png deleted file mode 100644 index 788f542c3..000000000 Binary files a/cake/cake-lwjgl3/src/main/resources/libgdx128.png and /dev/null differ diff --git a/cake/cake-lwjgl3/src/main/resources/libgdx16.png b/cake/cake-lwjgl3/src/main/resources/libgdx16.png deleted file mode 100644 index 47af1899a..000000000 Binary files a/cake/cake-lwjgl3/src/main/resources/libgdx16.png and /dev/null differ diff --git a/cake/cake-lwjgl3/src/main/resources/libgdx32.png b/cake/cake-lwjgl3/src/main/resources/libgdx32.png deleted file mode 100644 index 4cf903a41..000000000 Binary files a/cake/cake-lwjgl3/src/main/resources/libgdx32.png and /dev/null differ diff --git a/cake/cake-lwjgl3/src/main/resources/libgdx64.png b/cake/cake-lwjgl3/src/main/resources/libgdx64.png deleted file mode 100644 index ebcd8f103..000000000 Binary files a/cake/cake-lwjgl3/src/main/resources/libgdx64.png and /dev/null differ diff --git a/cake/cake-modelviewer/build.gradle b/cake/cake-viewer/build.gradle similarity index 73% rename from cake/cake-modelviewer/build.gradle rename to cake/cake-viewer/build.gradle index 0b56a2bd8..5bceb272f 100644 --- a/cake/cake-modelviewer/build.gradle +++ b/cake/cake-viewer/build.gradle @@ -5,7 +5,7 @@ plugins { } application { - mainClass.set('org.demoth.cake.lwjgl3.Lwjgl3ModelViewerLauncher') + mainClass.set('org.demoth.cake.lwjgl3.Lwjgl3ViewerLauncher') } apply from: rootProject.file('cake/desktop.gradle') diff --git a/cake/cake-modelviewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ModelViewerLauncher.kt b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt similarity index 71% rename from cake/cake-modelviewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ModelViewerLauncher.kt rename to cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt index bcff4ab35..184c26552 100644 --- a/cake/cake-modelviewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ModelViewerLauncher.kt +++ b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt @@ -1,11 +1,13 @@ package org.demoth.cake.lwjgl3 +import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3WindowAdapter import com.badlogic.gdx.graphics.glutils.HdpiMode -import org.demoth.cake.modelviewer.CakeModelViewer +import org.demoth.cake.viewer.CakeViewer -object Lwjgl3ModelViewerLauncher { +object Lwjgl3ViewerLauncher { @JvmStatic fun main(args: Array) { if (startNewJvmIfRequired(args)) return // This handles macOS support and helps on Windows. @@ -15,11 +17,19 @@ object Lwjgl3ModelViewerLauncher { } private fun createApplication(args: Array): Lwjgl3Application { - return Lwjgl3Application(CakeModelViewer(args), createConfiguration()) + val viewer = CakeViewer(args) + val configuration = createConfiguration().apply { + setWindowListener(object : Lwjgl3WindowAdapter() { + override fun filesDropped(files: Array) { + files.firstOrNull()?.let { path -> Gdx.app.postRunnable { viewer.openFile(path) } } + } + }) + } + return Lwjgl3Application(viewer, configuration) } private fun createConfiguration() = Lwjgl3ApplicationConfiguration().apply { - setTitle("Cake Model Viewer 1.2.2") + setTitle("Cake Viewer 1.2.2") setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) useVsync(true) //// Limits FPS to the refresh rate of the currently active monitor. diff --git a/cake/core/src/main/kotlin/org/demoth/cake/CakeStartupBootstrap.kt b/cake/core/src/main/kotlin/org/demoth/cake/CakeStartupBootstrap.kt index e180f68f7..9bc7d49e9 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/CakeStartupBootstrap.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/CakeStartupBootstrap.kt @@ -51,6 +51,7 @@ object CakeStartupBootstrap { private fun loadProfileConfig(profileId: String, profileConfigStore: CakeProfileConfigStore) { try { val configText = profileConfigStore.readConfig(profileId) ?: return + // TODO: cleanup: avoid "after restart" messages for saved video settings applied before window creation. Cbuf.AddAndExecuteScript(configText) Cvar.getInstance().updateLatchedVars() Com.Printf("Loaded Cake profile config for '$profileId'\n") diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ShaderCompatibility.kt b/cake/core/src/main/kotlin/org/demoth/cake/ShaderCompatibility.kt index 018e94927..642d6aa55 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ShaderCompatibility.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ShaderCompatibility.kt @@ -4,7 +4,7 @@ import com.badlogic.gdx.graphics.glutils.ShaderProgram /** * Configures LibGDX shader source prefixes for the GL3 pipeline used by Cake. - * Shared between the game client and the standalone model viewer. + * Shared between the game client and the standalone viewer. */ fun initializeShaderCompatibility() { ShaderProgram.prependVertexCode = """ diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt index 243eb93d2..9374c3de0 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt @@ -3,6 +3,7 @@ package org.demoth.cake.assets import com.badlogic.gdx.files.FileHandle import jake2.qcommon.vfs.DefaultVirtualFileSystem import jake2.qcommon.vfs.VfsConfig +import jake2.qcommon.vfs.VfsEntry import jake2.qcommon.vfs.VfsSourceType import java.nio.file.Files import java.nio.file.Path @@ -14,13 +15,14 @@ import java.nio.file.Path * to temporary files so libGDX loaders can consume standard [FileHandle]s. */ internal class CakeVfsAssetSource { - private val vfs = DefaultVirtualFileSystem() + private var vfs = DefaultVirtualFileSystem() private val extractedPackageFiles = mutableMapOf() private var configuredBasedir: String? = null private var configuredGameMod: String? = null private var configuredCaseSensitive: Boolean = false private var initialized = false + private var archiveContext = false @Synchronized fun configure(basedir: String?, gameMod: String?, caseSensitive: Boolean) { @@ -28,6 +30,7 @@ internal class CakeVfsAssetSource { val normalizedGameMod = gameMod?.takeIf { it.isNotBlank() } if ( initialized && + !archiveContext && configuredBasedir == normalizedBasedir && configuredGameMod == normalizedGameMod && configuredCaseSensitive == caseSensitive @@ -38,6 +41,7 @@ internal class CakeVfsAssetSource { configuredBasedir = normalizedBasedir configuredGameMod = normalizedGameMod configuredCaseSensitive = caseSensitive + archiveContext = false clearExtractedPackageFiles() @@ -57,6 +61,25 @@ internal class CakeVfsAssetSource { initialized = true } + /** Mounts only the selected archive, retaining the previous context if mounting fails. */ + @Synchronized + fun configureArchive(archivePath: String): List { + val archiveVfs = DefaultVirtualFileSystem() + archiveVfs.configure( + VfsConfig( + null, "baseq2", null, false, false, false, emptyList(), + setOf("pak", "pk2", "pk3", "pkz", "zip"), + ), + ) + val mounted = archiveVfs.mountPackage(archivePath) + require(mounted.success) { mounted.error ?: "Could not mount archive: $archivePath" } + clearExtractedPackageFiles() + vfs = archiveVfs + archiveContext = true + initialized = true + return vfs.listEntries() + } + @Synchronized fun resolve(path: String): FileHandle? { val lookup = vfs.resolve(path) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/bsp/BspLoader.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/bsp/BspLoader.kt index c27c6d815..dfc7c3017 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/bsp/BspLoader.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/bsp/BspLoader.kt @@ -17,6 +17,8 @@ import com.badlogic.gdx.graphics.g3d.Material import com.badlogic.gdx.graphics.g3d.Model import com.badlogic.gdx.graphics.g3d.attributes.TextureAttribute import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.math.collision.BoundingBox import com.badlogic.gdx.utils.Array import com.badlogic.gdx.utils.Disposable import jake2.qcommon.Com @@ -36,6 +38,7 @@ import kotlin.math.floor * [inlineRenderData] stores stable per-face part metadata for inline brush models (`*1`, `*2`, ...). * [lightmapAtlas] describes packed BSP lightmap pages generated at load time. * [generatedTextures] are runtime-generated lightmap atlas page textures (4 style-slot textures per page). + * [entities] and world [bounds] retain decoded map metadata for consumers such as resource previews. * BSP textures are loaded as independent AssetManager dependencies and are not disposed here. */ class BspMapAsset( @@ -47,6 +50,8 @@ class BspMapAsset( val lightmapAtlas: BspLightmapAtlasMetadata? = null, val lightmapAtlasPages: List = emptyList(), val generatedTextures: List = emptyList(), + val entities: List> = emptyList(), + val bounds: BoundingBox = BoundingBox(), ) : Disposable { override fun dispose() { generatedTextures.forEach { it.dispose() } @@ -284,6 +289,10 @@ class BspLoader(resolver: FileHandleResolver) : SynchronousAssetLoader_face_, + val bounds: BoundingBox, + val frameNames: List, + val skinNames: List, ) : Disposable { override fun dispose() { model.dispose() @@ -87,7 +94,7 @@ class Md2Loader(resolver: FileHandleResolver) : SynchronousAssetLoader|`, keep defaults. - * - Model viewer: keep embedded skins enabled and enable default fallback. + * - Cake Viewer: keep embedded skins enabled and enable default fallback. */ data class Parameters( val loadEmbeddedSkins: Boolean = true, @@ -159,7 +166,14 @@ class Md2Loader(resolver: FileHandleResolver) : SynchronousAssetLoader|) -> Md2Shader: sample animation textures, interpolate positions, draw ``` -`Md2Asset` contains the model, frame count, and ordered skin textures. The model +`Md2Asset` contains the model, frame count and stored names, ordered skin textures, and bounds +covering decoded vertex positions across all frames. The viewer uses these bounds +for camera framing; ordinary mesh bounds cannot represent the VAT geometry, +whose mesh contains animation indices rather than position attributes. The model viewer and transient model effects use the same loader/shader pipeline as gameplay entities. The shared `CakeShaderProvider` dispatches MD2, BSP, and default shaders; its cross-pipeline routing remains outside this package. @@ -26,8 +29,11 @@ its cross-pipeline routing remains outside this package. `Md2Loader` chooses skins in this order: a synthetic key's prefix; otherwise embedded MD2 skin paths if enabled; otherwise an optional generated white 1-by-1 skin. -The model viewer enables `useDefaultSkinIfMissing` for models with no selected skin +The viewer enables `useDefaultSkinIfMissing` for models with no selected skin paths. That option does not recover arbitrary failures to load a referenced file. +`Md2Asset.skinNames` retains the resolved paths in texture order, or an empty list +for the generated fallback. The viewer uses these names in its skin selector and +changes the preview instance's `skinIndex` without reloading assets. Player variants use keys such as `players/male/grunt.pcx|players/male/tris.md2`. `PlayerConfiguration` selects the diff --git a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/CakeModelViewer.kt b/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/CakeModelViewer.kt deleted file mode 100644 index 36ed60e26..000000000 --- a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/CakeModelViewer.kt +++ /dev/null @@ -1,291 +0,0 @@ -package org.demoth.cake.modelviewer - -import com.badlogic.gdx.ApplicationAdapter -import com.badlogic.gdx.Gdx -import com.badlogic.gdx.Input -import com.badlogic.gdx.assets.AssetManager -import com.badlogic.gdx.graphics.* -import com.badlogic.gdx.graphics.VertexAttributes.Usage -import com.badlogic.gdx.graphics.g2d.BitmapFont -import com.badlogic.gdx.graphics.g2d.SpriteBatch -import com.badlogic.gdx.graphics.g3d.* -import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute -import com.badlogic.gdx.graphics.g3d.environment.DirectionalLight -import com.badlogic.gdx.graphics.g3d.shaders.DefaultShader -import com.badlogic.gdx.graphics.g3d.utils.CameraInputController -import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder -import com.badlogic.gdx.math.Vector3 -import ktx.assets.TextAssetLoader -import ktx.graphics.use -import org.demoth.cake.ByteArrayLoader -import org.demoth.cake.initializeShaderCompatibility -import org.demoth.cake.md2FragmentShader -import org.demoth.cake.assets.bsp.BspLoader -import org.demoth.cake.assets.bsp.BspMapAsset -import org.demoth.cake.assets.md2.Md2Asset -import org.demoth.cake.assets.md2.Md2CustomData -import org.demoth.cake.assets.md2.Md2Loader -import org.demoth.cake.assets.ObjectLoader -import org.demoth.cake.assets.PcxLoader -import org.demoth.cake.assets.md2.Md2Shader -import org.demoth.cake.assets.CakeShaderProvider -import org.demoth.cake.assets.WalLoader -import org.demoth.cake.assets.getLoaded -import org.demoth.cake.md2VertexShader -import java.io.File -import kotlin.system.measureTimeMillis - -private const val GRID_SIZE = 16f -private const val GRID_DIVISIONS = 8 - -private val SUPPORTED_FORMATS = listOf(".bsp", ".pcx", ".md2", ".wal") - -class CakeModelViewer(val args: Array) : ApplicationAdapter() { - private lateinit var batch: SpriteBatch - private var image: Texture? = null - private lateinit var modelBatch: ModelBatch - private lateinit var assetManager: AssetManager - private lateinit var camera: Camera - private lateinit var cameraInputController: CameraInputController - // rendered with the default libgdx shader - private val instances: MutableList = mutableListOf() - // models created directly in the viewer (grid/origin), not owned by AssetManager - private val ownedModels: MutableList = mutableListOf() - private lateinit var environment: Environment - private lateinit var font: BitmapFont - private var frameTime = 0f // to have an idea of the fps - - // md2 related stuff - private var md2Frames = 1 - private var md2Instance: ModelInstance? = null // to control the model animation - private var md2AnimationFrameTime = 0.1f - private var md2FrameLerp = 0.0f - private var playingMd2Animation = false - - override fun create() { - initializeShaderCompatibility() - - if (args.isEmpty() || SUPPORTED_FORMATS.none { args.first().lowercase().endsWith(it) }) { - println("Usage: provide $SUPPORTED_FORMATS file as the first argument") - Gdx.app.exit() - return - } - val file = File(expandTildePath(args[0])) - if (!file.exists() || !file.canRead()) { - println("File $file does not exist or is unreadable") - Gdx.app.exit() - return - } - val startupHints = detectModelViewerStartupHints(file.absolutePath) - - val fileResolver = ModelViewerFileResolver( - openedFilePath = file.absolutePath, - basedir = startupHints.basedir, - gamemod = startupHints.gamemod, - ) - assetManager = AssetManager(fileResolver).apply { - setLoader(String::class.java, TextAssetLoader(fileResolver)) - setLoader(Any::class.java, ObjectLoader(fileResolver)) - setLoader(ByteArray::class.java, ByteArrayLoader(fileResolver)) - setLoader(Texture::class.java, "pcx", PcxLoader(fileResolver)) - setLoader(Texture::class.java, "wal", WalLoader(fileResolver)) - setLoader(BspMapAsset::class.java, "bsp", BspLoader(fileResolver)) - setLoader(Md2Asset::class.java, "md2", Md2Loader(fileResolver)) - } - - modelBatch = ModelBatch() - font = BitmapFont() - - when (file.extension.lowercase()) { - "pcx" -> { - image = assetManager.getLoaded(file.absolutePath) - } - "wal" -> { - image = assetManager.getLoaded(file.absolutePath) - } - "md2" -> { - // Viewer mode: prefer embedded MD2 skins, but allow rendering without them. - val md2 = assetManager.getLoaded( - file.absolutePath, - Md2Loader.Parameters( - useDefaultSkinIfMissing = true, - ), - ) - md2Instance = ModelInstance(md2.model).apply { - userData = Md2CustomData.empty() - } - md2Frames = md2.frames - - val tempRenderable = Renderable() - val md2Shader = Md2Shader( - md2Instance!!.getRenderable(tempRenderable), // may not be obvious, but it's required for the shader initialization, the renderable is not used after that - DefaultShader.Config( - assetManager.getLoaded(md2VertexShader), - assetManager.getLoaded(md2FragmentShader), - ) - ) - md2Shader.init() - - modelBatch.dispose() - modelBatch = ModelBatch(CakeShaderProvider(md2Shader)) - - instances.add(md2Instance!!) - addOwnedInstance(createOriginArrows(GRID_SIZE)) - addOwnedInstance(createGrid(GRID_SIZE, GRID_DIVISIONS)) - } - "bsp" -> { - val bspMap = assetManager.getLoaded(file.absolutePath) - bspMap.models.forEach { model -> - instances.add(ModelInstance(model)) - } - addOwnedInstance(createOriginArrows(GRID_SIZE)) - addOwnedInstance(createGrid(GRID_SIZE, GRID_DIVISIONS)) - } - else -> { - println("Unsupported file format ${file.extension}") - Gdx.app.exit() - return - } - } - - batch = SpriteBatch() - - camera = PerspectiveCamera(90f, Gdx.graphics.width.toFloat(), Gdx.graphics.height.toFloat()) - camera.position.set(0f, 0f, 0f); - camera.near = 1f - camera.far = 4096f - camera.up.set(0f, 0f, 1f) // make z up - camera.direction.set(0f, 1f, 0f) // make y forward - - cameraInputController = FlyingCameraController(camera) - Gdx.input.inputProcessor = cameraInputController - - environment = Environment() - environment.set(ColorAttribute(ColorAttribute.AmbientLight, 0.8f, 0.8f, 0.8f, 1f)) - environment.add(DirectionalLight().set(0.8f, 0.8f, 0.8f, -1f, -0.2f, 0.8f)) - } - - override fun render() { - if (!::camera.isInitialized || !::batch.isInitialized || !::modelBatch.isInitialized) { - return - } - - val md2Data = md2Instance?.let { it.userData as? Md2CustomData } - - if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) { - playingMd2Animation = !playingMd2Animation - } else if (Gdx.input.isKeyJustPressed(Input.Keys.ESCAPE)) { - Gdx.app.exit() - } else if (Gdx.input.isKeyJustPressed(Input.Keys.RIGHT) && md2Data != null) { - md2FrameLerp = 0f - changeFrame(1, md2Data, md2Frames) - } else if (Gdx.input.isKeyJustPressed(Input.Keys.LEFT) && md2Data != null) { - md2FrameLerp = 0f - changeFrame(-1, md2Data, md2Frames) - } - - if (playingMd2Animation && md2Data != null) { - md2FrameLerp += Gdx.graphics.deltaTime - if (md2FrameLerp >= md2AnimationFrameTime) { - md2FrameLerp = 0f - changeFrame(1, md2Data, md2Frames) - } - md2Data.interpolation = md2FrameLerp / md2AnimationFrameTime - - } - - frameTime = measureTimeMillis { - Gdx.gl.glClearColor(0.0f, 0.0f, 0.0f, 1f) - Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT or GL20.GL_DEPTH_BUFFER_BIT) - camera.update() - - - if (instances.isNotEmpty()) { - modelBatch.begin(camera) - instances.forEach { - modelBatch.render(it) - } - modelBatch.end() - - } - - batch.use { - // draw frame time in the bottom left corner - font.draw(batch, "Frame time ms: $frameTime", 0f, font.lineHeight) - - if (image != null) { - // draw image in the center of the screen - val x = (Gdx.graphics.width - image!!.width) / 2f - val y = (Gdx.graphics.height - image!!.height) / 2f - it.draw(image, x, y) - } - - } - - cameraInputController.update() - }.toFloat() - } - - override fun dispose() { - if (::batch.isInitialized) { - batch.dispose() - } - ownedModels.forEach { it.dispose() } - ownedModels.clear() - if (::modelBatch.isInitialized) { - modelBatch.dispose() - } - if (::assetManager.isInitialized) { - assetManager.dispose() - } - } - - private fun addOwnedInstance(instance: ModelInstance) { - instances.add(instance) - ownedModels.add(instance.model) - } -} - -internal fun expandTildePath(path: String): String { - if (path == "~") { - return System.getProperty("user.home") - } - if (path.startsWith("~/") || path.startsWith("~\\")) { - return System.getProperty("user.home") + path.substring(1) - } - return path -} - -private fun changeFrame(delta: Int, md2CustomData: Md2CustomData, md2Frames: Int) { - // Keep frame1 as the previous pose and frame2 as the target pose for smooth interpolation. - md2CustomData.frame1 = md2CustomData.frame2 - md2CustomData.frame2 = (md2CustomData.frame2 + delta) % md2Frames - if (md2CustomData.frame1 < 0) { - md2CustomData.frame1 += md2Frames - } - if (md2CustomData.frame2 < 0) { - md2CustomData.frame2 += md2Frames - } - md2CustomData.interpolation = 0f -} - -fun createGrid(size: Float, divisions: Int): ModelInstance { - val modelBuilder = ModelBuilder() - // grid is in XZ plane - val lineGrid = modelBuilder.createLineGrid( - divisions, divisions, size, size, Material( - ColorAttribute.createDiffuse(Color.GREEN) - ), (Usage.Position or Usage.ColorUnpacked).toLong() - ) - val modelInstance = ModelInstance(lineGrid).apply { - // rotate into XY plane - transform.rotate(Vector3.X, -90f) - } - return modelInstance -} - -fun createOriginArrows(size: Float): ModelInstance { - val modelBuilder = ModelBuilder() - val origin = modelBuilder.createXYZCoordinates(size, Material(), (Usage.Position or Usage.ColorUnpacked).toLong()) - return ModelInstance(origin) -} 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 c4ad4bcc7..cafe26ffa 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 @@ -2,22 +2,20 @@ package org.demoth.cake.stages import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Label -import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.exec.Cbuf -import ktx.actors.onClick import org.demoth.cake.BuildVersion import org.demoth.cake.ui.GameUiStyle import org.demoth.cake.ui.menu.MenuEventBus import org.demoth.cake.ui.menu.MenuIntent /** - * TEMPORARY Main menu stage for pre-game navigation commands. + * Main menu stage for profile selection and game navigation. * * Purpose: - * - Exposes basic startup actions (`connect`, `quit`) and placeholders. - * - In future will allow selecting concrete game configurations to run (q1/q2 etc) + * - Opens profile editing, multiplayer, and options screens and offers disconnect/exit actions. + * - Shows Singleplayer when hosting support is available. * * Ownership/Lifecycle: * - Created by [org.demoth.cake.Cake] and rebuilt when the shared content style changes. @@ -27,7 +25,7 @@ import org.demoth.cake.ui.menu.MenuIntent * Invariants/Constraints: * - Menu buttons are laid out in a single table column with uniform width * (`uniformX().fillX()`). - * - Button actions enqueue console commands through `Cbuf`. + * - Navigation and disconnect actions post [MenuIntent] messages; Exit queues `quit` through [Cbuf]. */ class MainMenuStage( viewport: Viewport, 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 fbeed3e87..fe8f9e88b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/README.md @@ -11,7 +11,8 @@ The developer console has [its own documentation](console/README.md). |---|---| | `MainMenuStage` | Main navigation and profile/options entry points | | `ProfileEditStage` | Profile selection, creation, autodetection, and editing | -| `MultiplayerMenuStage` | Join Game, disabled Host Game, and Player Setup | +| `MultiplayerMenuStage` | Join Game, optional Host Game, and Player Setup | +| `HostingSetupStage` | Singleplayer difficulty and multiplayer host settings | | `JoinGameStage` | Manual server address entry and validation feedback | | `PlayerSetupStage` | Player name, password, model, skin, and handedness | | `OptionsMenuStage` | Available client option sections | @@ -31,6 +32,8 @@ Menu stages present state and collect edits; navigation state lives in `ui.menu` `Cake` owns their lifetime and rebuilds content-styled stages when the shared style changes. They must not be treated as permanent instances across style changes. +- `cake-client` hides Singleplayer and Host Game entries. `cake-fullgame` exposes + them and enables hosting when the current profile and session state allow it. - Join Game validates host/port before using Cake's existing connection path. - Player Setup writes on Save; Back discards staged edits. The backend writes userinfo cvars, including derived gender, and saves profile configuration. 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 d5583cb89..77d8edf42 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 @@ -21,7 +21,7 @@ import org.demoth.cake.config.GameConfiguration import org.demoth.cake.assets.md2.Md2Asset import org.demoth.cake.assets.md2.Md2CustomData import org.demoth.cake.createModelInstance -import org.demoth.cake.modelviewer.createOriginArrows +import org.demoth.cake.viewer.createOriginArrows import kotlin.math.abs // responsible for managing entity states which are updated from the server diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt new file mode 100644 index 000000000..7692fac3b --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt @@ -0,0 +1,229 @@ +package org.demoth.cake.ui + +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.InputListener +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.utils.Align +import com.badlogic.gdx.utils.Scaling +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.ui.SplitPane +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.Tree +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener +import jake2.qcommon.vfs.VfsEntry + +/** Folder hierarchy derived from a resolved VFS snapshot; files retain their original metadata. */ +internal class ContentDirectory(val path: String, val name: String) { + val folders = sortedMapOf() + val files = mutableListOf() + + companion object { + fun fromEntries(entries: List, rootName: String): ContentDirectory { + val root = ContentDirectory("", rootName) + entries.sortedBy { it.normalizedPath }.forEach { entry -> + val parts = entry.normalizedPath.split('/') + val displayParts = entry.displayPath.replace('\\', '/').split('/').filter { it.isNotEmpty() } + var directory = root + parts.dropLast(1).forEachIndexed { index, part -> + val path = if (directory.path.isEmpty()) part else "${directory.path}/$part" + directory = directory.folders.getOrPut(part) { + ContentDirectory(path, displayParts.getOrElse(index) { part }) + } + } + directory.files += entry + } + return root + } + } +} + +/** + * Browse a resolved entry snapshot. The host owns preview and thumbnail resources. + * [previewSelection] replaces/releases the full preview; null clears it. + * [loadThumbnail] borrows a texture from the host for this folder's lifetime. + * [clearThumbnails] releases the folder's textures when navigating to another folder. + * [onSelect] may supply a borrowed inline preview for the selected tile. + */ +class ContentBrowser( + entries: List, rootName: String, skin: Skin, + private val previewSelection: (VfsEntry?) -> Actor? = { null }, + private val loadThumbnail: (VfsEntry) -> Texture? = { null }, + private val clearThumbnails: () -> Unit = {}, + private val onSelect: (VfsEntry?) -> Actor? = { null }, +) : Table(skin) { + private class FolderNode(val directory: ContentDirectory, skin: Skin) : + Tree.Node(Label(directory.name, skin)) { + init { value = directory } + } + + private class Tile(val directory: ContentDirectory?, val entry: VfsEntry?, skin: Skin) : Button(ButtonStyle().apply { + over = skin.getDrawable("selection") + down = over + checked = over + }) { + val thumbnail = entry?.normalizedPath?.substringAfterLast('.') in setOf("pcx", "wal", "tga") + val placeholder = Label(if (directory != null) "Folder" else if (thumbnail) "…" else + entry!!.normalizedPath.substringAfterLast('.').uppercase(), skin).apply { + setEllipsis(true) + setAlignment(Align.center) + } + val picture = Container(placeholder).apply { fill() } + var attempted = false + + init { + val name = directory?.let { "${it.name}/" } ?: entry!!.displayPath.replace('\\', '/').substringAfterLast('/') + add(picture).size(128f, 96f).row() + add(Label(name, skin).apply { setEllipsis(true) }).width(128f).padTop(4f) + pad(6f) + } + } + + private val tree = Tree(skin) + private val grid = HorizontalGroup().apply { wrap(); top(); left(); space(8f); wrapSpace(8f); pad(8f) } + private val tiles = mutableListOf() + private var auditionTile: Tile? = null + private val pathLabel = Label("/", skin).apply { setWrap(true) } + private val selectionInfo = Label("Select a file to see its path and size.", skin).apply { + setEllipsis(true) + setAlignment(Align.topLeft) + } + private val nodes = mutableMapOf() + private val gridScroll = scrollPane(grid, skin).apply { setScrollingDisabled(true, false) } + private val content = Container(gridScroll).apply { fill() } + private val backButton = TextButton("Back to folder", skin).apply { isVisible = false } + + init { + background = skin.getDrawable("window") + pad(8f) + tree.selection.setMultiple(false) + tree.selection.required = true + val listing = Table().apply { + defaults().left().growX() + add(Table().apply { + add(pathLabel).growX().left() + add(backButton).padLeft(8f) + }).pad(8f).row() + add(content).grow().row() + // Reserve both path and size lines before selection, keeping tiles under the pointer. + add(selectionInfo).minWidth(0f).height(selectionInfo.style.font.data.lineHeight * 2f).pad(8f).row() + } + add(SplitPane(scrollPane(tree, skin), listing, false, skin).apply { splitAmount = 0.3f }).grow() + + val root = addFolder(ContentDirectory.fromEntries(entries, rootName), skin) + tree.add(root) + root.isExpanded = true + tree.addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { + tree.selectedValue?.let { showDirectory(it) } + } + }) + backButton.addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { + clearAudition() + previewSelection(null) + content.actor = gridScroll + backButton.isVisible = false + } + }) + tree.selection.set(root) + } + + override fun act(delta: Float) { + super.act(delta) + if (content.actor !== gridScroll || stage == null) return + validate() + gridScroll.validate() + grid.validate() + // Compare in grid coordinates, including the ScrollPane's visual (smoothed) scroll. + val top = grid.height - gridScroll.visualScrollY + val bottom = top - gridScroll.scrollHeight + val pending = tiles.filter { it.thumbnail && !it.attempted && it.y < top && it.y + it.height > bottom } + val started = System.nanoTime() + // Load small images together. A single synchronous load can exceed this 4 ms budget. + for (tile in pending) { + tile.attempted = true + val texture = loadThumbnail(tile.entry!!) + tile.picture.actor = texture?.let { Image(it).apply { setScaling(Scaling.fit) } } + ?: tile.placeholder.apply { setText("Unavailable") } + if (System.nanoTime() - started >= 4_000_000L) break + } + } + + private fun addFolder(directory: ContentDirectory, skin: Skin): FolderNode { + val node = FolderNode(directory, skin) + nodes[directory.path] = node + directory.folders.values.forEach { node.add(addFolder(it, skin)) } + return node + } + + private fun showDirectory(directory: ContentDirectory) { + clearAudition() + previewSelection(null) + grid.clearChildren() + tiles.clear() + clearThumbnails() + content.actor = gridScroll + backButton.isVisible = false + pathLabel.setText(if (directory.path.isEmpty()) "/" else "/${directory.path}/") + directory.folders.values.forEach { addTile(it, null) } + directory.files.forEach { addTile(null, it) } + gridScroll.scrollY = 0f + gridScroll.updateVisualScroll() + selectionInfo.setText("Select a file to see its path and size.") + } + + private fun addTile(directory: ContentDirectory?, entry: VfsEntry?) { + val tile = Tile(directory, entry, skin) + tiles += tile + grid.addActor(tile) + tile.addListener(object : ClickListener() { + override fun clicked(event: InputEvent, x: Float, y: Float) { + tiles.forEach { it.isChecked = it === tile } + selectionInfo.setText(entry?.let { "${it.displayPath}\n${it.size} bytes" } ?: "${directory!!.path}/") + if (tapCount == 1) { + clearAudition() + onSelect(entry)?.let { preview -> + tile.picture.actor = preview + auditionTile = tile + } + } + if (tapCount != 2) return + if (directory != null) { + val node = nodes.getValue(directory.path) + node.expandTo() + node.isExpanded = true + tree.selection.set(node) + } else { + val preview = previewSelection(entry) ?: return + content.actor = scrollPane(preview, skin) + backButton.isVisible = true + } + } + }) + } + + private fun clearAudition() { + auditionTile?.let { it.picture.actor = it.placeholder } + auditionTile = null + } + + private fun scrollPane(actor: Actor, skin: Skin) = ScrollPane(actor, skin).apply { + setFadeScrollBars(false) + setFlickScroll(false) + addListener(object : InputListener() { + override fun mouseMoved(event: InputEvent, x: Float, y: Float): Boolean { + stage?.scrollFocus = this@apply + return false + } + }) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/BspPreviewPane.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/BspPreviewPane.kt new file mode 100644 index 000000000..2e7f6a359 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/BspPreviewPane.kt @@ -0,0 +1,101 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Camera +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.InputListener +import com.badlogic.gdx.scenes.scene2d.Stage +import org.demoth.cake.assets.bsp.BspMapAsset +import kotlin.math.cos +import kotlin.math.sin + +/** Free flight without gameplay or collision; movement belongs only to the focused pane. */ +internal class BspPreviewPane(map: BspMapAsset, renderMap: (Camera) -> Unit) : ScenePreviewPane(renderMap) { + private var yaw = 0f + private var pitch = 0f + private val movement = Vector3() + private val right = Vector3() + private val movementKeys = setOf(Input.Keys.W, Input.Keys.A, Input.Keys.S, Input.Keys.D, Input.Keys.Q, Input.Keys.E) + private val releaseFocus = object : InputListener() { + override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean { + if (event.target !== this@BspPreviewPane && stage?.keyboardFocus === this@BspPreviewPane) { + stage?.keyboardFocus = null + } + return false + } + } + + init { + val spawn = sequenceOf("info_player_start", "info_player_deathmatch", "info_player_coop") + .mapNotNull { type -> + val candidates = map.entities.filter { it["classname"] == type && it["origin"] != null } + // Named starts are entrances from other maps; prefer the normal level entry. + candidates.firstOrNull { it["targetname"].isNullOrBlank() } ?: candidates.firstOrNull() + } + .firstOrNull() + val origin = spawn?.get("origin")?.trim()?.split(Regex("\\s+"))?.mapNotNull { it.toFloatOrNull() } + if (origin?.size == 3) { + camera.position.set(origin[0], origin[1], origin[2] + 22f) + yaw = spawn["angle"]?.toFloatOrNull() ?: 0f + } else if (map.bounds.isValid) { + map.bounds.getCenter(camera.position) + } + camera.near = 1f + camera.far = if (map.bounds.isValid) map.bounds.getDimensions(Vector3()).len().coerceAtLeast(4096f) else 4096f + addListener(object : InputListener() { + private var lastX = 0f + private var lastY = 0f + + override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean { + if (button != Input.Buttons.LEFT) return false + stage?.keyboardFocus = this@BspPreviewPane + lastX = x; lastY = y + event.stop() + return true + } + + override fun touchDragged(event: InputEvent, x: Float, y: Float, pointer: Int) { + yaw -= (x - lastX) * 0.3f + pitch = (pitch + (y - lastY) * 0.3f).coerceIn(-85f, 85f) + lastX = x; lastY = y + event.stop() + } + + override fun keyDown(event: InputEvent, keycode: Int): Boolean = keycode in movementKeys + override fun keyUp(event: InputEvent, keycode: Int): Boolean = keycode in movementKeys + }) + } + + override fun setStage(stage: Stage?) { + this.stage?.removeCaptureListener(releaseFocus) + super.setStage(stage) + stage?.addCaptureListener(releaseFocus) + } + + override fun act(delta: Float) { + super.act(delta) + if (stage?.keyboardFocus !== this) return + movement.setZero() + right.set(camera.direction).crs(Vector3.Z).nor() + if (Gdx.input.isKeyPressed(Input.Keys.W)) movement.add(camera.direction) + if (Gdx.input.isKeyPressed(Input.Keys.S)) movement.sub(camera.direction) + if (Gdx.input.isKeyPressed(Input.Keys.D)) movement.add(right) + if (Gdx.input.isKeyPressed(Input.Keys.A)) movement.sub(right) + if (Gdx.input.isKeyPressed(Input.Keys.E)) movement.z += 1f + if (Gdx.input.isKeyPressed(Input.Keys.Q)) movement.z -= 1f + camera.position.mulAdd(movement.nor(), 500f * delta.coerceAtMost(0.1f)) + } + + override fun updateCamera(width: Int, height: Int) { + camera.viewportWidth = width.toFloat() + camera.viewportHeight = height.toFloat() + val yawRadians = Math.toRadians(yaw.toDouble()).toFloat() + val pitchRadians = Math.toRadians(pitch.toDouble()).toFloat() + camera.direction.set(cos(yawRadians) * cos(pitchRadians), sin(yawRadians) * cos(pitchRadians), sin(pitchRadians)) + right.set(camera.direction).crs(Vector3.Z).nor() + camera.up.set(right).crs(camera.direction).nor() + camera.update() + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/CakeViewer.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/CakeViewer.kt new file mode 100644 index 000000000..27129fb10 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/CakeViewer.kt @@ -0,0 +1,608 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import com.badlogic.gdx.InputMultiplexer +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.graphics.* +import com.badlogic.gdx.graphics.VertexAttributes.Usage +import com.badlogic.gdx.graphics.g2d.SpriteBatch +import com.badlogic.gdx.graphics.g3d.* +import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute +import com.badlogic.gdx.graphics.g3d.environment.DirectionalLight +import com.badlogic.gdx.graphics.g3d.shaders.DefaultShader +import com.badlogic.gdx.graphics.g3d.utils.CameraInputController +import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.viewport.ScreenViewport +import com.badlogic.gdx.utils.Scaling +import jake2.qcommon.vfs.VfsEntry +import ktx.assets.TextAssetLoader +import ktx.graphics.use +import org.demoth.cake.ByteArrayLoader +import org.demoth.cake.cakeSkin +import org.demoth.cake.audio.SoundDurationRegistry +import org.demoth.cake.initializeShaderCompatibility +import org.demoth.cake.md2FragmentShader +import org.demoth.cake.assets.bsp.BspLoader +import org.demoth.cake.assets.bsp.BspMapAsset +import org.demoth.cake.assets.bsp.BspWorldBatchRenderer +import org.demoth.cake.assets.md2.Md2Asset +import org.demoth.cake.assets.md2.Md2CustomData +import org.demoth.cake.assets.md2.Md2Loader +import org.demoth.cake.assets.ObjectLoader +import org.demoth.cake.assets.PcxLoader +import org.demoth.cake.assets.md2.Md2Shader +import org.demoth.cake.assets.CakeShaderProvider +import org.demoth.cake.assets.ConvertingSoundLoader +import org.demoth.cake.assets.Sp2Asset +import org.demoth.cake.assets.Sp2Loader +import org.demoth.cake.assets.WalLoader +import org.demoth.cake.assets.getLoaded +import org.demoth.cake.md2VertexShader +import org.demoth.cake.ui.ContentBrowser +import java.io.File +import kotlin.system.measureTimeMillis + +private const val GRID_SIZE = 16f +private const val GRID_DIVISIONS = 8 + +private val SUPPORTED_FORMATS = listOf(".bsp", ".pcx", ".md2", ".wal", ".tga", ".wav", ".sp2", ".pak") + +class CakeViewer(val args: Array) : ApplicationAdapter() { + private lateinit var batch: SpriteBatch + private var image: Texture? = null + private var sprite: Sp2Asset? = null + private var playback: FramePlayback? = null + private val spriteFrame: Sp2Asset.Frame? get() = sprite?.frames?.get(playback!!.frameIndex) + private var sound: Sound? = null + private var soundPlayback: SoundPlayback? = null + private var bspMap: BspMapAsset? = null + private var bspWorldRenderer: BspWorldBatchRenderer? = null + private var bspVisibleSurfaces = BooleanArray(0) + private var bspTime = 0f + private var modelBatch: ModelBatch? = null + private lateinit var fileResolver: ViewerFileResolver + private var currentFile: File? = null + private var archiveEntryCount: Int? = null + private val archiveThumbnails = mutableMapOf() + private val failedThumbnails = mutableSetOf() + private lateinit var previewResources: PreviewResources + private lateinit var assetManager: AssetManager + private lateinit var camera: Camera + private lateinit var cameraInputController: CameraInputController + // rendered with the default libgdx shader + private val instances: MutableList = mutableListOf() + private lateinit var environment: Environment + private lateinit var stage: Stage + private lateinit var browserHost: Container + private lateinit var filenameLabel: Label + private lateinit var controlsLabel: Label + private lateinit var errorLabel: Label + private lateinit var resourceInfo: Label + private lateinit var frameTimeLabel: Label + private var frameTime = 0f // to have an idea of the fps + + // md2 related stuff + private var md2Frames = 1 + private var md2SkinTextures = 0 + private var md2Instance: ModelInstance? = null // to control the model animation + private var md2FrameNames: List = emptyList() + + override fun create() { + initializeShaderCompatibility() + + if (args.isEmpty() || SUPPORTED_FORMATS.none { args.first().lowercase().endsWith(it) }) { + println("Usage: provide $SUPPORTED_FORMATS file as the first argument") + Gdx.app.exit() + return + } + val file = File(expandTildePath(args[0])) + if (!file.exists() || !file.canRead()) { + println("File $file does not exist or is unreadable") + Gdx.app.exit() + return + } + val startupHints = detectViewerStartupHints(file.absolutePath) + + fileResolver = ViewerFileResolver( + openedFilePath = file.absolutePath, + basedir = startupHints.basedir, + gamemod = startupHints.gamemod, + ) + assetManager = AssetManager(fileResolver).apply { + setLoader(String::class.java, TextAssetLoader(fileResolver)) + setLoader(Any::class.java, ObjectLoader(fileResolver)) + setLoader(ByteArray::class.java, ByteArrayLoader(fileResolver)) + setLoader(Texture::class.java, "pcx", PcxLoader(fileResolver)) + setLoader(Texture::class.java, "wal", WalLoader(fileResolver)) + setLoader(Sound::class.java, ConvertingSoundLoader(fileResolver)) + setLoader(Sp2Asset::class.java, Sp2Loader(fileResolver)) + setLoader(BspMapAsset::class.java, "bsp", BspLoader(fileResolver)) + setLoader(Md2Asset::class.java, "md2", Md2Loader(fileResolver)) + } + + previewResources = PreviewResources(assetManager) + batch = SpriteBatch() + + camera = PerspectiveCamera(90f, Gdx.graphics.width.toFloat(), Gdx.graphics.height.toFloat()) + camera.near = 1f + camera.far = 4096f + resetCamera() + + environment = Environment() + environment.set(ColorAttribute(ColorAttribute.AmbientLight, 0.8f, 0.8f, 0.8f, 1f)) + environment.add(DirectionalLight().set(0.8f, 0.8f, 0.8f, -1f, -0.2f, 0.8f)) + createStage() + openFile(file.absolutePath) + } + + /** Replaces the preview. Call on the application thread, between render calls. */ + fun openFile(path: String) { + val file = File(expandTildePath(path)).absoluteFile + val rejection = when { + SUPPORTED_FORMATS.none { file.name.lowercase().endsWith(it) } -> "Unsupported file: ${file.name}" + !file.isFile || !file.canRead() -> "File is missing or unreadable: ${file.name}" + else -> null + } + if (rejection != null) { + errorLabel.setText(rejection) + return + } + + closePreview() + resetCamera() + try { + currentFile = file + if (file.extension.equals("pak", ignoreCase = true)) { + val entries = fileResolver.configureArchive(file.absolutePath) + archiveEntryCount = entries.size + browserHost.actor = ContentBrowser(entries, file.name, assetManager.getLoaded(cakeSkin), + ::previewArchiveResource, ::loadArchiveThumbnail, ::clearArchiveThumbnails, ::selectArchiveResource) + browserHost.isVisible = true + } else { + val hints = detectViewerStartupHints(file.absolutePath) + fileResolver.configure(file.absolutePath, hints.basedir, hints.gamemod) + loadPreview(file.absolutePath) + if (md2Instance != null || bspMap != null) { + addOwnedInstance(createOriginArrows(GRID_SIZE)) + addOwnedInstance(createGrid(GRID_SIZE, GRID_DIVISIONS)) + } + if (sound != null) replaySound() + } + filenameLabel.setText(file.name) + controlsLabel.setText(when { + archiveEntryCount != null -> "Click: select / play sound or sprite | Double-click: open | Escape: close" + sound != null -> "Space: play/pause | S: stop | Escape: close" + sprite != null || md2Instance != null -> "Space: play/pause | Left/Right: step | Escape: close" + else -> "Escape: close" + }) + errorLabel.setText("") + updateResourceInfo() + } catch (error: Exception) { + Gdx.app.error("Cake Viewer", "Could not open ${file.absolutePath}", error) + recoverFromLoadError("Could not load ${file.name}. Drop another resource to continue.") + } + } + + private fun loadArchiveThumbnail(entry: VfsEntry): Texture? { + val path = entry.normalizedPath + archiveThumbnails[path]?.let { return it } + if (path in failedThumbnails) return null + return try { + val file = requireNotNull(fileResolver.resolve(path)) + // Invoke existing loaders directly: these textures are viewer-owned, and a bad + // thumbnail cannot leave failed tasks/dependency records in the shared manager. + val texture = when (path.substringAfterLast('.')) { + "pcx" -> PcxLoader(fileResolver).load(assetManager, path, file, null) + "wal" -> WalLoader(fileResolver).load(assetManager, path, file, WalLoader.Parameters( + externalPalette = assetManager.getLoaded("q2palette.bin") as IntArray)) + else -> Texture(file) + } + archiveThumbnails[path] = texture + texture + } catch (error: Exception) { + failedThumbnails += path + Gdx.app.error("Cake Viewer", "Could not thumbnail ${entry.displayPath}", error) + null + } + } + + private fun clearArchiveThumbnails() { + archiveThumbnails.values.forEach { it.dispose() } + archiveThumbnails.clear() + failedThumbnails.clear() + } + + private fun selectArchiveResource(entry: VfsEntry?): Actor? { + when (entry?.normalizedPath?.substringAfterLast('.')) { + "wav" -> if (loadArchiveResource(entry)) replaySound() + "sp2" -> if (loadArchiveResource(entry)) { + return playback?.let { + it.stop() + it.play() + SpriteFrameImage(sprite!!, it, Scaling.fit) + } + } + else -> closeResourcePreview() + } + return null + } + + private fun previewArchiveResource(entry: VfsEntry?): Actor? { + errorLabel.setText("") + if (entry == null) { + closeResourcePreview() + return null + } + if (entry.normalizedPath.substringAfterLast('.') !in listOf("pcx", "wal", "tga", "wav", "sp2", "md2", "bsp")) return null + if (!loadArchiveResource(entry)) return null + // Presentation borrows the active resource; opening controls does not start playback. + return sound?.let { SoundPreviewControls(it, soundPlayback!!, assetManager.getLoaded(cakeSkin)) } + ?: image?.let { + Table().apply { + add(ImagePreviewPane(Image(it))).grow().row() + add(Label("Drag: pan | Wheel: zoom", assetManager.getLoaded(cakeSkin))).pad(4f) + } + } + ?: sprite?.let { SpritePreview(it, playback!!, assetManager.getLoaded(cakeSkin)) } + ?: bspMap?.let { map -> + val pane = previewResources.own(BspPreviewPane(map, ::renderScene)) + Table().apply { + add(pane).grow().row() + add(Label("Surfaces: ${map.worldRenderData.surfaces.size} | Brush models: ${map.inlineRenderData.size}", + assetManager.getLoaded(cakeSkin))).pad(4f).row() + add(Label("Click to focus | WASD: fly | Q/E: down/up | Drag: look", + assetManager.getLoaded(cakeSkin)).apply { setWrap(true) }).growX().pad(4f) + } + } + ?: md2Instance?.let { instance -> + val md2 = assetManager.get(entry.normalizedPath, Md2Asset::class.java) + val animation = playback!! + val pane = previewResources.own(Md2PreviewPane(md2.bounds) { paneCamera -> + modelBatch!!.begin(paneCamera) + modelBatch!!.render(instance) + modelBatch!!.end() + }) + Table().apply { + add(pane).grow().row() + add(Md2SkinSelector(md2, instance.userData as Md2CustomData, + assetManager.getLoaded(cakeSkin))).minWidth(0f).growX().pad(4f).row() + add(FramePlaybackControls(animation, assetManager.getLoaded(cakeSkin)) { + "${md2.frameNames[animation.frameIndex]} | Skin textures: ${md2.skins.size}" + }).growX().row() + add(Label("Drag: orbit | Wheel: zoom", assetManager.getLoaded(cakeSkin))).pad(4f) + } + } + } + + private fun renderScene(camera: Camera) { + val currentTimeMs = (bspTime * 1000f).toInt() + bspWorldRenderer?.render( + camera = camera, + visibleSurfaceMask = bspVisibleSurfaces, + currentTimeMs = currentTimeMs, + lightStyleResolver = { 1f }, + dynamicLights = emptyList(), + ) + + modelBatch?.let { models -> + models.begin(camera) + instances.forEach { models.render(it) } + models.end() + } + + bspWorldRenderer?.renderTranslucent(camera, bspVisibleSurfaces, currentTimeMs) + } + + private fun loadArchiveResource(entry: VfsEntry): Boolean { + errorLabel.setText("") + if (previewResources.path == entry.normalizedPath) return true + val archive = currentFile + return try { + loadPreview(entry.normalizedPath) + true + } catch (error: Exception) { + Gdx.app.error("Cake Viewer", "Could not preview ${entry.displayPath}", error) + recoverFromLoadError("Could not preview ${entry.displayPath}.", archive) + false + } + } + + private fun replaySound() { + soundPlayback?.replay() + } + + private fun recoverFromLoadError(message: String, archiveToReopen: File? = null) { + closePreview() + // Failed dependency loads can leave assets and dependency records behind. + stage.dispose() + assetManager.clear() + createStage() + if (archiveToReopen != null) openFile(archiveToReopen.absolutePath) + errorLabel.setText(message) + } + + /** Same resource lifetime for loose absolute paths and archive logical paths. */ + private fun loadPreview(path: String) { + closeResourcePreview() + when (path.substringAfterLast('.').lowercase()) { + "sp2" -> { + sprite = previewResources.load(path, Sp2Asset::class.java) + playback = FramePlayback(sprite!!.frames.size) + } + "wav" -> { + sound = previewResources.load(path, Sound::class.java) + soundPlayback = SoundPlayback(sound!!) + } + "pcx", "wal", "tga" -> { + image = previewResources.load(path, Texture::class.java) + } + "md2" -> { + // Viewer mode: prefer embedded MD2 skins, but allow rendering without them. + val md2 = previewResources.load( + path, Md2Asset::class.java, + Md2Loader.Parameters( + useDefaultSkinIfMissing = true, + ), + ) + md2Instance = ModelInstance(md2.model).apply { + userData = Md2CustomData.empty() + } + md2Frames = md2.frames + md2FrameNames = md2.frameNames + playback = FramePlayback(md2.frames) + md2SkinTextures = md2.skins.size + + val tempRenderable = Renderable() + val md2Shader = Md2Shader( + md2Instance!!.getRenderable(tempRenderable), // may not be obvious, but it's required for the shader initialization, the renderable is not used after that + DefaultShader.Config( + assetManager.getLoaded(md2VertexShader), + assetManager.getLoaded(md2FragmentShader), + ) + ) + modelBatch = previewResources.own(ModelBatch(CakeShaderProvider(md2Shader))) + md2Shader.init() + + instances.add(md2Instance!!) + } + "bsp" -> { + val bspMap = previewResources.load(path, BspMapAsset::class.java) + modelBatch = previewResources.own(ModelBatch()) + this.bspMap = bspMap + bspWorldRenderer = previewResources.own(BspWorldBatchRenderer( + worldRenderData = bspMap.worldRenderData, + worldBatchData = bspMap.worldBatchData, + lightmapAtlasPages = bspMap.lightmapAtlasPages, + assetManager = assetManager, + )) + // Free-flight inspection shows the whole world without gameplay visibility state. + bspVisibleSurfaces = BooleanArray(bspMap.worldRenderData.surfaces.size) { true } + val invisibleBrushes = bspMap.entities.filter { + it["classname"]?.startsWith("trigger_") == true || it["classname"] == "func_areaportal" + }.mapNotNull { it["model"]?.removePrefix("*")?.toIntOrNull() }.toSet() + bspMap.models.forEachIndexed { index, model -> + if (index !in invisibleBrushes) instances.add(ModelInstance(model)) + } + } + } + } + + private fun resetCamera() { + camera.position.setZero() + camera.up.set(0f, 0f, 1f) + camera.direction.set(0f, 1f, 0f) + camera.update() + cameraInputController = FlyingCameraController(camera) + if (::stage.isInitialized) Gdx.input.inputProcessor = InputMultiplexer(stage, cameraInputController) + } + + private fun createStage() { + val skin = assetManager.getLoaded(cakeSkin) + stage = Stage(ScreenViewport()) + stage.viewport.update(Gdx.graphics.width, Gdx.graphics.height, true) + resourceInfo = Label("", skin) + frameTimeLabel = Label("", skin) + filenameLabel = Label("No resource", skin).apply { setWrap(true) } + controlsLabel = Label("Drop a resource to view | Escape: close", skin).apply { setWrap(true) } + errorLabel = Label("", skin).apply { setWrap(true) } + browserHost = Container().apply { + fill() + isVisible = false + } + val panel = Table(skin).apply { + background = skin.getDrawable("window") + pad(12f) + defaults().left().growX().padBottom(4f) + add(filenameLabel).row() + add(resourceInfo).row() + add(controlsLabel).row() + add(errorLabel).row() + add(frameTimeLabel) + } + stage.addActor(Table().apply { + setFillParent(true) + top().left().pad(12f) + add(panel).growX().row() + add(browserHost).grow() + }) + updateResourceInfo() + Gdx.input.inputProcessor = InputMultiplexer(stage, cameraInputController) + } + + private fun updateResourceInfo() { + val frame = spriteFrame + val bsp = bspMap + val durationMs = sound?.let { SoundDurationRegistry.durationMs(it) } + resourceInfo.setText(when { + archiveEntryCount != null -> "Files: $archiveEntryCount" + frame != null -> "Frame ${playback!!.frameIndex + 1}/${sprite!!.frames.size} | ${frame.width} x ${frame.height}" + image != null -> "${image!!.width} x ${image!!.height}" + md2Instance != null -> "Frame ${playback!!.frameIndex + 1}/$md2Frames: ${md2FrameNames[playback!!.frameIndex]} | Skin textures: $md2SkinTextures" + sound != null -> durationMs?.let { "Duration: ${it / 1000f} s" } ?: "Duration: unknown" + bsp != null -> "World surfaces: ${bsp.worldRenderData.surfaces.size} | Inline brush models: ${bsp.inlineRenderData.size}" + else -> "" + }) + resourceInfo.isVisible = resourceInfo.text.isNotEmpty() + } + + override fun resize(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + if (::stage.isInitialized) stage.viewport.update(width, height, true) + if (::batch.isInitialized) batch.projectionMatrix.setToOrtho2D(0f, 0f, width.toFloat(), height.toFloat()) + if (::camera.isInitialized) { + camera.viewportWidth = width.toFloat() + camera.viewportHeight = height.toFloat() + camera.update() + } + } + + override fun render() { + if (!::camera.isInitialized || !::batch.isInitialized || !::stage.isInitialized) { + return + } + + soundPlayback?.update() + if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) { + if (sound != null) soundPlayback?.toggle() else playback?.toggle() + } else if (Gdx.input.isKeyJustPressed(Input.Keys.ESCAPE)) { + Gdx.app.exit() + } else if (sound != null && Gdx.input.isKeyJustPressed(Input.Keys.S)) { + soundPlayback?.stop() + } else if (Gdx.input.isKeyJustPressed(Input.Keys.RIGHT)) { + playback?.step(1) + } else if (Gdx.input.isKeyJustPressed(Input.Keys.LEFT)) { + playback?.step(-1) + } + + playback?.let { + it.update(Gdx.graphics.deltaTime) + (md2Instance?.userData as? Md2CustomData)?.apply { + frame1 = it.frameIndex + frame2 = it.nextFrameIndex + interpolation = it.interpolation + } + updateResourceInfo() + } + + frameTime = measureTimeMillis { + Gdx.gl.glClearColor(0.0f, 0.0f, 0.0f, 1f) + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT or GL20.GL_DEPTH_BUFFER_BIT) + camera.update() + + bspTime += Gdx.graphics.deltaTime + if (archiveEntryCount == null) renderScene(camera) + + batch.use { + val currentFrame = if (archiveEntryCount == null) spriteFrame else null + val currentImage = if (archiveEntryCount == null) currentFrame?.texture ?: image else null + if (currentImage != null) { + // draw image in the center of the screen + val width = (currentFrame?.width ?: currentImage.width).toFloat() + val height = (currentFrame?.height ?: currentImage.height).toFloat() + val x = (Gdx.graphics.width - width) / 2f + val y = (Gdx.graphics.height - height) / 2f + it.draw(currentImage, x, y, width, height) + } + + } + + cameraInputController.update() + frameTimeLabel.setText("Frame time ms: $frameTime") + stage.act(Gdx.graphics.deltaTime) + stage.viewport.apply() + stage.draw() + }.toFloat() + } + + override fun dispose() { + closePreview() + if (::stage.isInitialized) { + Gdx.input.inputProcessor = null + stage.dispose() + } + if (::batch.isInitialized) { + batch.dispose() + } + if (::assetManager.isInitialized) { + assetManager.dispose() + } + } + + private fun closePreview() { + closeResourcePreview() + clearArchiveThumbnails() + archiveEntryCount = null + if (::browserHost.isInitialized) { + browserHost.actor = null + browserHost.isVisible = false + } + currentFile = null + } + + private fun closeResourcePreview() { + soundPlayback?.stop() + if (::previewResources.isInitialized) previewResources.close() + bspWorldRenderer = null + modelBatch = null + instances.clear() + image = null + sound = null + soundPlayback = null + sprite = null + playback = null + bspMap = null + bspVisibleSurfaces = BooleanArray(0) + bspTime = 0f + md2Instance = null + md2Frames = 1 + md2SkinTextures = 0 + md2FrameNames = emptyList() + } + + private fun addOwnedInstance(instance: ModelInstance) { + instances.add(instance) + previewResources.own(instance.model) + } +} + +internal fun expandTildePath(path: String): String { + if (path == "~") { + return System.getProperty("user.home") + } + if (path.startsWith("~/") || path.startsWith("~\\")) { + return System.getProperty("user.home") + path.substring(1) + } + return path +} + +fun createGrid(size: Float, divisions: Int): ModelInstance { + val modelBuilder = ModelBuilder() + // grid is in XZ plane + val lineGrid = modelBuilder.createLineGrid( + divisions, divisions, size, size, Material( + ColorAttribute.createDiffuse(Color.GREEN) + ), (Usage.Position or Usage.ColorUnpacked).toLong() + ) + val modelInstance = ModelInstance(lineGrid).apply { + // rotate into XY plane + transform.rotate(Vector3.X, -90f) + } + return modelInstance +} + +fun createOriginArrows(size: Float): ModelInstance { + val modelBuilder = ModelBuilder() + val origin = modelBuilder.createXYZCoordinates(size, Material(), (Usage.Position or Usage.ColorUnpacked).toLong()) + return ModelInstance(origin) +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/FlyingCameraController.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FlyingCameraController.kt similarity index 98% rename from cake/core/src/main/kotlin/org/demoth/cake/modelviewer/FlyingCameraController.kt rename to cake/core/src/main/kotlin/org/demoth/cake/viewer/FlyingCameraController.kt index 0bb6fe78d..ad8a39122 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/FlyingCameraController.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FlyingCameraController.kt @@ -1,4 +1,4 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Camera diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlayback.kt new file mode 100644 index 000000000..d666058fc --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlayback.kt @@ -0,0 +1,56 @@ +package org.demoth.cake.viewer + +/** Fixed-rate frame playback for sprite and MD2 previews, independent of rendering. */ +internal class FramePlayback(val frameCount: Int) { + init { require(frameCount > 0) } + var frameIndex = 0 + private set + var playing = false + private set + var looping = false + private set + val nextFrameIndex get() = if (looping) (frameIndex + 1) % frameCount else (frameIndex + 1).coerceAtMost(frameCount - 1) + val interpolation get() = elapsed / 0.1f + private var elapsed = 0f + private var finished = false + + fun play(loop: Boolean = false) { + if (finished) { frameIndex = 0; elapsed = 0f } + finished = false + looping = loop + playing = true + } + + fun pause() { playing = false } + fun toggle() { if (playing) pause() else play(looping) } + + fun stop() { + playing = false + looping = false + finished = false + frameIndex = 0 + elapsed = 0f + } + + fun step(delta: Int) { + pause() + frameIndex = Math.floorMod(frameIndex + delta, frameCount) + elapsed = 0f + finished = false + } + + fun update(delta: Float) { + if (!playing) return + elapsed += delta + val steps = (elapsed / 0.1f).toInt() + if (steps == 0) return + elapsed %= 0.1f + if (looping) frameIndex = (frameIndex + steps) % frameCount + else if (frameIndex + steps >= frameCount) { + frameIndex = frameCount - 1 + playing = false + finished = true + elapsed = 0f + } else frameIndex += steps + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlaybackControls.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlaybackControls.kt new file mode 100644 index 000000000..435cb895d --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/FramePlaybackControls.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener + +/** Shared sprite/model transport; playback state and resources remain host-owned. */ +internal class FramePlaybackControls( + private val playback: FramePlayback, skin: Skin, private val description: () -> String, +) : Table(skin) { + private val info = Label("", skin).apply { setWrap(true) } + private val buttons = HorizontalGroup().apply { wrap(); space(4f); wrapSpace(4f) } + + init { + defaults().pad(4f) + add(info).growX().row() + button("Previous") { playback.step(-1) } + button("Play") { playback.play() } + button("Pause") { playback.pause() } + button("Loop") { playback.play(loop = true) } + button("Stop") { playback.stop() } + button("Next") { playback.step(1) } + add(buttons).growX().row() + add(Label("Space: play/pause | Left/Right: step", skin).apply { setWrap(true) }).growX() + updateInfo() + } + + override fun act(delta: Float) { super.act(delta); updateInfo() } + private fun updateInfo() { + info.setText("Frame ${playback.frameIndex + 1}/${playback.frameCount} | ${description()}") + } + + private fun button(text: String, action: () -> Unit) { + buttons.addActor(TextButton(text, skin).apply { + addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { action() } + }) + }) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/ImagePreviewPane.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ImagePreviewPane.kt new file mode 100644 index 000000000..30432a771 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ImagePreviewPane.kt @@ -0,0 +1,95 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.InputListener +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup +import com.badlogic.gdx.utils.Scaling +import kotlin.math.min +import kotlin.math.pow + +/** Borrows an image; zoom and pan are relative to its aspect-preserving fit in this pane. */ +internal class ImagePreviewPane(private val image: Image) : WidgetGroup() { + private val canvasWidth = image.prefWidth + private val canvasHeight = image.prefHeight + private var zoom = 1f + private var panX = 0f + private var panY = 0f + + init { + touchable = Touchable.enabled + image.setScaling(Scaling.fit) + addActor(image) + addListener(object : InputListener() { + private var lastX = 0f + private var lastY = 0f + + override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean { + if (button != Input.Buttons.LEFT) return false + lastX = x; lastY = y + stage?.scrollFocus = this@ImagePreviewPane + event.stop() + return true + } + + override fun touchDragged(event: InputEvent, x: Float, y: Float, pointer: Int) { + if (width <= 0f || height <= 0f) return + panX += (x - lastX) / width + panY += (y - lastY) / height + lastX = x; lastY = y + invalidate() + event.stop() + } + + override fun mouseMoved(event: InputEvent, x: Float, y: Float): Boolean { + stage?.scrollFocus = this@ImagePreviewPane + event.stop() + return true + } + + override fun scrolled(event: InputEvent, x: Float, y: Float, amountX: Float, amountY: Float): Boolean { + if (x < 0f || y < 0f || x >= width || y >= height) return false + val nextZoom = (zoom * 1.1f.pow(-amountY)).coerceIn(0.05f, 32f) + val ratio = nextZoom / zoom + // Keep the image point under the pointer fixed while changing scale. + panX = (x - width / 2f - (x - width / 2f - panX * width) * ratio) / width + panY = (y - height / 2f - (y - height / 2f - panY * height) * ratio) / height + zoom = nextZoom + invalidate() + event.stop() + return true + } + }) + } + + override fun hit(x: Float, y: Float, touchable: Boolean): Actor? { + if (x < 0f || y < 0f || x >= width || y >= height) return null + return super.hit(x, y, touchable) + } + + override fun layout() { + if (canvasWidth <= 0f || canvasHeight <= 0f) return + val scale = min(width / canvasWidth, height / canvasHeight) * zoom + val imageWidth = canvasWidth * scale + val imageHeight = canvasHeight * scale + image.setBounds(width / 2f + panX * width - imageWidth / 2f, + height / 2f + panY * height - imageHeight / 2f, imageWidth, imageHeight) + } + + override fun draw(batch: Batch, parentAlpha: Float) { + validate() + batch.flush() + if (clipBegin(x, y, width, height)) { + try { + super.draw(batch, parentAlpha) + batch.flush() + } finally { + clipEnd() + } + } + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2PreviewPane.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2PreviewPane.kt new file mode 100644 index 000000000..67b93ee11 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2PreviewPane.kt @@ -0,0 +1,72 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Camera +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.math.collision.BoundingBox +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.InputListener +import kotlin.math.* + +/** The host owns model/shader resources; this widget owns its camera and pane-sized render target. */ +internal class Md2PreviewPane(bounds: BoundingBox, renderModel: (Camera) -> Unit) : ScenePreviewPane(renderModel) { + private val center = bounds.getCenter(Vector3()) + private val radius = (bounds.getDimensions(Vector3()).len() / 2f).coerceAtLeast(0.1f) + private var yaw = -45f + private var pitch = 20f + private var zoom = 1f + + init { + addListener(object : InputListener() { + private var lastX = 0f + private var lastY = 0f + + override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean { + if (button != Input.Buttons.LEFT) return false + lastX = x; lastY = y + stage?.scrollFocus = this@Md2PreviewPane + stage?.keyboardFocus = this@Md2PreviewPane + event.stop() + return true + } + + override fun touchDragged(event: InputEvent, x: Float, y: Float, pointer: Int) { + yaw -= (x - lastX) * 0.5f + pitch = (pitch - (y - lastY) * 0.5f).coerceIn(-85f, 85f) + lastX = x; lastY = y + event.stop() + } + + override fun mouseMoved(event: InputEvent, x: Float, y: Float): Boolean { + stage?.scrollFocus = this@Md2PreviewPane + event.stop() + return true + } + + override fun scrolled(event: InputEvent, x: Float, y: Float, amountX: Float, amountY: Float): Boolean { + if (x < 0f || y < 0f || x >= width || y >= height) return false + zoom = (zoom * 1.1f.pow(amountY)).coerceIn(0.1f, 30f) + event.stop() + return true + } + }) + } + + override fun updateCamera(width: Int, height: Int) { + camera.viewportWidth = width.toFloat() + camera.viewportHeight = height.toFloat() + val halfFov = Math.toRadians(camera.fieldOfView.toDouble() / 2).toFloat() + val limitingFov = min(halfFov, atan(tan(halfFov) * width / height)) + val distance = radius / sin(limitingFov) * 1.1f * zoom + val yawRadians = Math.toRadians(yaw.toDouble()).toFloat() + val pitchRadians = Math.toRadians(pitch.toDouble()).toFloat() + camera.position.set(cos(yawRadians) * cos(pitchRadians), sin(yawRadians) * cos(pitchRadians), sin(pitchRadians)) + .scl(distance).add(center) + camera.up.set(0f, 0f, 1f) + camera.lookAt(center) + camera.near = radius * 0.01f + camera.far = distance + radius * 2f + camera.update() + } + +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2SkinSelector.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2SkinSelector.kt new file mode 100644 index 000000000..1f03cf339 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/Md2SkinSelector.kt @@ -0,0 +1,27 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.SelectBox +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import org.demoth.cake.assets.md2.MAX_MD2_SKIN_TEXTURES +import org.demoth.cake.assets.md2.Md2Asset +import org.demoth.cake.assets.md2.Md2CustomData + +/** Selects an already-bound texture on this model instance without loading or replacing assets. */ +internal class Md2SkinSelector(model: Md2Asset, data: Md2CustomData, skin: Skin) : SelectBox(skin) { + init { + val choices = model.skins.indices.take(MAX_MD2_SKIN_TEXTURES).map { index -> + val name = model.skinNames.getOrNull(index)?.substringAfterLast('/') ?: "Default skin" + "Skin ${index + 1}: $name" + } + setItems(*choices.toTypedArray()) + selectedIndex = data.skinIndex.coerceIn(0, choices.lastIndex) + isDisabled = choices.size == 1 + addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { + data.skinIndex = selectedIndex + } + }) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/PreviewResources.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/PreviewResources.kt new file mode 100644 index 000000000..4290b00c4 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/PreviewResources.kt @@ -0,0 +1,37 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.utils.Disposable + +/** + * Owns one loaded root's AssetManager reference and any directly created preview resources. + * Dependencies remain managed by AssetManager. Close before changing the resolver context. + * A failed load still requires close, followed by the host's AssetManager error recovery. + */ +internal class PreviewResources(private val assets: AssetManager) : AutoCloseable { + var path: String? = null + private set + private var acquired = false + private val owned = mutableListOf() + + fun load(path: String, type: Class, parameters: AssetLoaderParameters? = null): T { + check(this.path == null) { "Close the previous preview before loading another resource" } + this.path = path + assets.load(path, type, parameters) + // finishLoadingAsset can return before a queued reference increment for a cached asset. + assets.finishLoading() + acquired = true + return assets.get(path, type) + } + + fun own(resource: T): T = resource.also { owned += it } + + override fun close() { + owned.asReversed().forEach { it.dispose() } + owned.clear() + path?.let { if (acquired && assets.isLoaded(it)) assets.unload(it) } + path = null + acquired = false + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/ScenePreviewPane.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ScenePreviewPane.kt new file mode 100644 index 000000000..8a3fa1756 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ScenePreviewPane.kt @@ -0,0 +1,63 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Camera +import com.badlogic.gdx.graphics.GL20 +import com.badlogic.gdx.graphics.PerspectiveCamera +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.graphics.g2d.TextureRegion +import com.badlogic.gdx.graphics.glutils.FrameBuffer +import com.badlogic.gdx.graphics.glutils.HdpiUtils +import com.badlogic.gdx.scenes.scene2d.ui.Widget +import com.badlogic.gdx.utils.Disposable +import kotlin.math.* + +/** Owns the pane-sized render target; scene assets belong to the host's preview scope. */ +internal abstract class ScenePreviewPane(private val renderScene: (Camera) -> Unit) : Widget(), Disposable { + protected val camera = PerspectiveCamera(60f, 1f, 1f) + private var target: FrameBuffer? = null + private var region: TextureRegion? = null + + override fun draw(batch: Batch, parentAlpha: Float) { + if (width <= 0f || height <= 0f) return + batch.end() + val scissors = Gdx.gl.glIsEnabled(GL20.GL_SCISSOR_TEST) + try { + Gdx.gl.glDisable(GL20.GL_SCISSOR_TEST) + val pixelWidth = HdpiUtils.toBackBufferX(ceil(width).toInt()).coerceAtLeast(1) + val pixelHeight = HdpiUtils.toBackBufferY(ceil(height).toInt()).coerceAtLeast(1) + if (target?.width != pixelWidth || target?.height != pixelHeight) { + val replacement = FrameBuffer(Pixmap.Format.RGBA8888, pixelWidth, pixelHeight, true) + replacement.colorBufferTexture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear) + target?.dispose() + target = replacement + region = TextureRegion(replacement.colorBufferTexture).apply { flip(false, true) } + } + updateCamera(pixelWidth, pixelHeight) + target!!.begin() + Gdx.gl.glClearColor(0f, 0f, 0f, 1f) + Gdx.gl.glDepthMask(true) + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT or GL20.GL_DEPTH_BUFFER_BIT) + renderScene(camera) + } finally { + target?.end() + stage?.viewport?.apply() + if (scissors) Gdx.gl.glEnable(GL20.GL_SCISSOR_TEST) + batch.begin() + } + val previousColor = batch.packedColor + batch.setColor(color.r, color.g, color.b, color.a * parentAlpha) + batch.draw(region, x, y, width, height) + batch.packedColor = previousColor + } + + protected abstract fun updateCamera(width: Int, height: Int) + + override fun dispose() { + target?.dispose() + target = null + region = null + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPlayback.kt new file mode 100644 index 000000000..ace215ed2 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPlayback.kt @@ -0,0 +1,68 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.audio.Sound +import org.demoth.cake.audio.SoundDurationRegistry + +/** Owns one playback instance, borrowing the sound from the active preview's asset scope. */ +internal class SoundPlayback( + private val sound: Sound, + durationMs: Int? = SoundDurationRegistry.durationMs(sound), + private val now: () -> Long = System::nanoTime, +) { + var playing = false + private set + var looping = false + private set + private var id = -1L + private var elapsed = 0L + private var lastUpdate = now() + private val duration = durationMs?.takeIf { it > 0 }?.toLong()?.times(1_000_000L) + + fun play(loop: Boolean = false) { + update() + if (id == -1L) { + id = if (loop) sound.loop() else sound.play() + if (id == -1L) return + } else { + if (looping != loop) sound.setLooping(id, loop) + if (!playing) sound.resume(id) + } + playing = true + looping = loop + lastUpdate = now() + } + + fun replay() { stop(); play() } + + fun pause() { + update() + if (!playing) return + sound.pause(id) + playing = false + } + + fun toggle() { + update() + if (playing) pause() else play(looping) + } + + fun stop() { + if (id != -1L) sound.stop(id) + id = -1L + playing = false + looping = false + elapsed = 0L + } + + fun update() { + val time = now() + if (playing) { + elapsed += (time - lastUpdate).coerceAtLeast(0L) + // Sound has no completion callback/query. Unknown durations stay active until stopped. + if (duration != null && elapsed >= duration) { + if (looping) elapsed %= duration else stop() + } + } + lastUpdate = time + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPreviewControls.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPreviewControls.kt new file mode 100644 index 000000000..80db07716 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SoundPreviewControls.kt @@ -0,0 +1,33 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import org.demoth.cake.audio.SoundDurationRegistry + +/** Borrows its sound from the active preview; removing the widget does not unload it. */ +internal class SoundPreviewControls(sound: Sound, playback: SoundPlayback, skin: Skin) : Table(skin) { + init { + val duration = SoundDurationRegistry.durationMs(sound) + defaults().pad(8f) + add(Label(duration?.let { "Duration: ${it / 1000f} s" } ?: "Duration: unknown", skin)).colspan(4).row() + button("Play") { playback.play() } + button("Pause") { playback.pause() } + button("Loop") { playback.play(loop = true) } + button("Stop") { playback.stop() } + row() + add(Label("Space: play/pause | S: stop", skin)).colspan(4) + } + + private fun button(text: String, action: () -> Unit) { + add(TextButton(text, skin).apply { + addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent, actor: Actor) { action() } + }) + }) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/viewer/SpritePreview.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SpritePreview.kt new file mode 100644 index 000000000..94d832d40 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/SpritePreview.kt @@ -0,0 +1,52 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable +import org.demoth.cake.assets.Sp2Asset +import com.badlogic.gdx.utils.Scaling + +/** Borrows frame textures. Fixed canvas dimensions keep the layout stable while frames change. */ +internal class SpriteFrameImage(private val sprite: Sp2Asset, private val playback: FramePlayback, scaling: Scaling) : Image() { + private var shownFrame = -1 + private val canvasWidth = sprite.frames.maxOf { it.width }.toFloat() + private val canvasHeight = sprite.frames.maxOf { it.height }.toFloat() + + init { setScaling(scaling); refresh() } + override fun getPrefWidth() = canvasWidth + override fun getPrefHeight() = canvasHeight + override fun act(delta: Float) { super.act(delta); refresh() } + + private fun refresh() { + if (shownFrame == playback.frameIndex) return + val frame = sprite.frames[playback.frameIndex] + drawable = object : TextureRegionDrawable(frame.texture) { + init { + minWidth = canvasWidth + minHeight = canvasHeight + } + + override fun draw(batch: Batch, x: Float, y: Float, width: Float, height: Float) { + val frameWidth = width * frame.width / canvasWidth + val frameHeight = height * frame.height / canvasHeight + super.draw(batch, x + (width - frameWidth) / 2f, y + (height - frameHeight) / 2f, + frameWidth, frameHeight) + } + } + shownFrame = playback.frameIndex + } +} + +internal class SpritePreview(sprite: Sp2Asset, playback: FramePlayback, skin: Skin) : Table(skin) { + init { + add(ImagePreviewPane(SpriteFrameImage(sprite, playback, Scaling.fit))).grow().row() + add(FramePlaybackControls(playback, skin) { + val frame = sprite.frames[playback.frameIndex] + "${frame.width} x ${frame.height}" + }).growX().row() + add(Label("Drag: pan | Wheel: zoom", skin)).pad(4f) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolver.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerFileResolver.kt similarity index 73% rename from cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolver.kt rename to cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerFileResolver.kt index dbed64411..5797297e8 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolver.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerFileResolver.kt @@ -1,4 +1,4 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import com.badlogic.gdx.Gdx import com.badlogic.gdx.assets.loaders.FileHandleResolver @@ -6,6 +6,7 @@ import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.utils.GdxRuntimeException import jake2.qcommon.vfs.DefaultVirtualFileSystem import jake2.qcommon.vfs.VfsConfig +import jake2.qcommon.vfs.VfsEntry import jake2.qcommon.vfs.VfsSourceType import org.demoth.cake.assets.CakeVfsAssetSource import java.io.File @@ -13,25 +14,40 @@ import java.nio.file.Path import java.util.LinkedHashSet /** - * Lightweight resolver for the standalone model viewer. + * Lightweight resolver for the standalone viewer. * * Lookup order: * 1. classpath/internal assets (viewer shaders, palette, icons) * 2. opened file folder and its parents via local VFS (for local model/map bundles) * 3. optional basedir/mod fallback via Cake VFS adapter (same convention as the game client) + * Archive mode resolves only bundled classpath assets and entries in the selected archive. */ -class ModelViewerFileResolver( +class ViewerFileResolver( openedFilePath: String, - private val basedir: String? = null, - private val gamemod: String? = null, + private var basedir: String? = null, + gamemod: String? = null, ) : FileHandleResolver { - private val openedFileDirectoryPath = File(openedFilePath).absoluteFile.parentFile?.absolutePath ?: "." - private val openedFileDirectory = Gdx.files.absolute(openedFileDirectoryPath) + private var openedFileDirectoryPath = File(openedFilePath).absoluteFile.parentFile?.absolutePath ?: "." + private var openedFileDirectory = Gdx.files.absolute(openedFileDirectoryPath) private val localVfs = DefaultVirtualFileSystem() private val gameDataVfs = CakeVfsAssetSource() + private var archiveContext = false init { + configure(openedFilePath, basedir, gamemod) + } + + /** + * Changes the resource context while preserving the resolver used by asset loaders. + * Call only after unloading the previous preview and finishing all asset-loading work. + * This does not invalidate AssetManager's cached assets. + */ + fun configure(openedFilePath: String, basedir: String? = null, gamemod: String? = null) { + archiveContext = false + this.basedir = basedir + openedFileDirectoryPath = File(openedFilePath).absoluteFile.parentFile?.absolutePath ?: "." + openedFileDirectory = Gdx.files.absolute(openedFileDirectoryPath) localVfs.configure( VfsConfig( null, @@ -47,9 +63,17 @@ class ModelViewerFileResolver( gameDataVfs.configure(basedir = basedir, gameMod = gamemod, caseSensitive = false) } + /** Changes to an isolated archive context under the same loading/unloading rules as [configure]. */ + fun configureArchive(archivePath: String): List { + val entries = gameDataVfs.configureArchive(archivePath) + archiveContext = true + return entries + } + override fun resolve(fileName: String): FileHandle? { - // Absolute paths can come from CLI args and should always be accepted. + // Loose previews accept absolute CLI paths; archive dependencies use logical paths. val absoluteCandidate = File(fileName) + if (archiveContext && absoluteCandidate.isAbsolute) return null if (absoluteCandidate.isAbsolute) { val absoluteHandle = Gdx.files.absolute(absoluteCandidate.path) if (absoluteHandle.exists()) { @@ -58,10 +82,13 @@ class ModelViewerFileResolver( } val normalized = fileName.replace('\\', '/') - val roots = listOf(Gdx.files.classpath(""), Gdx.files.internal("")) + val roots = if (archiveContext) listOf(Gdx.files.classpath("")) + else listOf(Gdx.files.classpath(""), Gdx.files.internal("")) resolveCaseSensitive(roots, fileName)?.let { return it } resolveCaseInsensitive(roots, normalized.lowercase())?.let { return it } + if (archiveContext) return gameDataVfs.resolve(fileName) + resolveFromLocalVfs(fileName)?.let { return it } if (basedir != null) { gameDataVfs.resolve(fileName)?.let { return it } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHints.kt b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerStartupHints.kt similarity index 88% rename from cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHints.kt rename to cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerStartupHints.kt index 6e73bf159..ca1053b96 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHints.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/viewer/ViewerStartupHints.kt @@ -1,21 +1,21 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import java.nio.file.Files import java.nio.file.Path -internal data class ModelViewerStartupHints( +internal data class ViewerStartupHints( val basedir: String? = null, val gamemod: String? = null, ) -internal fun detectModelViewerStartupHints( +internal fun detectViewerStartupHints( openedFilePath: String, currentDirectory: Path = Path.of("").toAbsolutePath().normalize(), -): ModelViewerStartupHints { +): ViewerStartupHints { val openedFile = Path.of(openedFilePath).toAbsolutePath().normalize() val basedir = detectBasedirCandidate(currentDirectory) ?: detectBasedirCandidate(openedFile.parent) val gamemod = basedir?.let { detectGameMod(openedFile, it) } - return ModelViewerStartupHints( + return ViewerStartupHints( basedir = basedir?.toString(), gamemod = gamemod, ) diff --git a/cake/core/src/main/resources/META-INF/native-image/org.demoth/cake/reachability-metadata.json b/cake/core/src/main/resources/META-INF/native-image/org.demoth/cake/reachability-metadata.json index 76eb2aa31..08fe86990 100644 --- a/cake/core/src/main/resources/META-INF/native-image/org.demoth/cake/reachability-metadata.json +++ b/cake/core/src/main/resources/META-INF/native-image/org.demoth/cake/reachability-metadata.json @@ -23,6 +23,207 @@ { "type": "com.badlogic.gdx.backends.lwjgl3.audio.Mp3$Music", "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.graphics.Color", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Skin$TintedDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.CheckBox$CheckBoxStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.ImageButton$ImageButtonStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.ImageTextButton$ImageTextButtonStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.List$ListStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.ProgressBar$ProgressBarStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.ScrollPane$ScrollPaneStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.SelectBox$SelectBoxStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Slider$SliderStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.SplitPane$SplitPaneStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.TextButton$TextButtonStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.TextField$TextFieldStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.TextTooltip$TextTooltipStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Touchpad$TouchpadStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Tree$TreeStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Window$WindowStyle", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.BaseDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.SpriteDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.TiledDrawable", + "allDeclaredFields": true, + "allPublicConstructors": true + }, + { + "type": "com.badlogic.gdx.graphics.g2d.GlyphLayout", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.graphics.g2d.GlyphLayout$GlyphRun", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.utils.Array", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.math.Rectangle", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.InputEvent", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.Stage$TouchFocus", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.FocusListener$FocusEvent", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.utils.ChangeListener$ChangeEvent", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.badlogic.gdx.scenes.scene2d.ui.Table$DebugRect", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] } ] } diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/ResolverParityIntegrationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/ResolverParityIntegrationTest.kt index 0d41ae886..c3e535bc8 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/assets/ResolverParityIntegrationTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/ResolverParityIntegrationTest.kt @@ -4,7 +4,7 @@ import com.badlogic.gdx.ApplicationListener import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.headless.HeadlessApplication import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration -import org.demoth.cake.modelviewer.ModelViewerFileResolver +import org.demoth.cake.viewer.ViewerFileResolver import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -21,14 +21,14 @@ import java.nio.file.Files import java.nio.file.Path import java.util.LinkedHashMap -/** Checks Cake/model-viewer resolution parity; asset cvars initialize the Cake resolver only. */ +/** Checks Cake/viewer resolution parity; asset cvars initialize the Cake resolver only. */ class ResolverParityIntegrationTest { @TempDir lateinit var temp: Path @Test - fun cakeAndModelViewerResolveSameWinnerForModLooseVsBasePack() { + fun cakeAndViewerResolveSameWinnerForModLooseVsBasePack() { val basedir = temp.toString() val openedFile = createFile("viewer/tris.md2") createFile("rogue/textures/wall.wal", "mod-loose".toByteArray(StandardCharsets.US_ASCII)) @@ -38,7 +38,7 @@ class ResolverParityIntegrationTest { ) val cakeResolver = CakeFileResolver(basedir = basedir, gamemod = "rogue") - val viewerResolver = ModelViewerFileResolver( + val viewerResolver = ViewerFileResolver( openedFilePath = openedFile.absolutePath, basedir = basedir, gamemod = "rogue", @@ -54,7 +54,7 @@ class ResolverParityIntegrationTest { } @Test - fun cakeAndModelViewerResolveSameWinnerForBaseZip() { + fun cakeAndViewerResolveSameWinnerForBaseZip() { val basedir = temp.toString() val openedFile = createFile("viewer/tris.md2") writeZip( @@ -63,7 +63,7 @@ class ResolverParityIntegrationTest { ) val cakeResolver = CakeFileResolver(basedir = basedir, gamemod = null) - val viewerResolver = ModelViewerFileResolver( + val viewerResolver = ViewerFileResolver( openedFilePath = openedFile.absolutePath, basedir = basedir, gamemod = null, diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentDirectoryTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentDirectoryTest.kt new file mode 100644 index 000000000..647ba8beb --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentDirectoryTest.kt @@ -0,0 +1,41 @@ +package org.demoth.cake.ui + +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.vfs.VfsLayer +import jake2.qcommon.vfs.VfsSource +import jake2.qcommon.vfs.VfsSourceType +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.nio.file.Path + +class ContentDirectoryTest { + @Test + fun buildsSortedFoldersAndImmediateFileListsWithOriginalMetadata() { + val entries = listOf("Textures/Z/B.pcx", "root.txt", "textures/a.wal", "Textures/Z/A.pcx", + "models/m.md2", "z.cfg", "a.cfg").map { path -> + VfsEntry(path.lowercase(), path, VfsLayer.BASE_PACK, 0, + VfsSource(VfsSourceType.PACKAGE_ENTRY, Path.of("content.pak"), path, "pak", true, false), 123, 0) + } + + val root = ContentDirectory.fromEntries(entries, "content.pak") + + assertEquals("content.pak", root.name) + assertEquals("", root.path) + assertEquals(listOf("models", "textures"), root.folders.keys.toList()) + assertEquals(listOf("a.cfg", "root.txt", "z.cfg"), root.files.map { it.normalizedPath }) + val textures = root.folders.getValue("textures") + assertEquals(listOf("textures/a.wal"), textures.files.map { it.normalizedPath }) + val nested = textures.folders.getValue("z") + assertEquals("Z", nested.name) + assertEquals("textures/z", nested.path) + assertEquals(listOf("textures/z/a.pcx", "textures/z/b.pcx"), nested.files.map { it.normalizedPath }) + assertSame(entries[3], nested.files.first()) + } + + @Test + fun emptySnapshotProducesAnEmptyRootFolder() { + val root = ContentDirectory.fromEntries(emptyList(), "empty.pak") + assertTrue(root.folders.isEmpty()) + assertTrue(root.files.isEmpty()) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/CakeModelViewerPathTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/CakeViewerPathTest.kt similarity index 90% rename from cake/core/src/test/kotlin/org/demoth/cake/modelviewer/CakeModelViewerPathTest.kt rename to cake/core/src/test/kotlin/org/demoth/cake/viewer/CakeViewerPathTest.kt index ba99a96d0..d64fbd66e 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/CakeModelViewerPathTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/CakeViewerPathTest.kt @@ -1,9 +1,9 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -class CakeModelViewerPathTest { +class CakeViewerPathTest { @Test fun expandsHomeOnlyPath() { diff --git a/cake/core/src/test/kotlin/org/demoth/cake/viewer/FramePlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/FramePlaybackTest.kt new file mode 100644 index 000000000..974f4c7e6 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/FramePlaybackTest.kt @@ -0,0 +1,83 @@ +package org.demoth.cake.viewer + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class FramePlaybackTest { + @Test + fun playsOnceAtTenFpsHoldsLastFrameAndCanRestart() { + val playback = FramePlayback(4) + assertFalse(playback.playing) + playback.play() + playback.update(0.11f) + assertEquals(1, playback.frameIndex) + playback.update(1f) + assertEquals(3, playback.frameIndex) + assertFalse(playback.playing) + playback.update(1f) + assertEquals(3, playback.frameIndex) + playback.play() + assertEquals(0, playback.frameIndex) + assertTrue(playback.playing) + } + + @Test + fun pausePreservesFrameAndFractionalTimeWhileStopResetsBoth() { + val playback = FramePlayback(4) + playback.play() + playback.update(0.16f) + assertEquals(2, playback.nextFrameIndex) + assertEquals(0.6f, playback.interpolation, 0.001f) + playback.pause() + playback.update(5f) + assertEquals(1, playback.frameIndex) + assertEquals(0.6f, playback.interpolation, 0.001f) + playback.toggle() + playback.update(0.06f) + assertEquals(2, playback.frameIndex) + playback.stop() + assertEquals(0, playback.frameIndex) + assertEquals(0f, playback.interpolation) + assertFalse(playback.playing) + playback.play() + playback.update(0.05f) + assertEquals(0, playback.frameIndex) + } + + @Test + fun loopingHandlesSeveralCyclesAndFrameSteppingPausesAndWraps() { + val playback = FramePlayback(3) + playback.play(loop = true) + playback.update(1.25f) + assertEquals(0, playback.frameIndex) + assertTrue(playback.playing) + playback.step(-1) + assertEquals(2, playback.frameIndex) + assertEquals(0, playback.nextFrameIndex) + assertEquals(0f, playback.interpolation) + assertFalse(playback.playing) + playback.step(1) + assertEquals(0, playback.frameIndex) + playback.toggle() + assertTrue(playback.looping) + playback.stop() + assertFalse(playback.looping) + } + + @Test + fun singlePlaybackClampsInterpolationAtLastFrameAndLoopingBlendsBackToFirst() { + val playback = FramePlayback(3) + playback.play() + playback.update(0.25f) + assertEquals(2, playback.frameIndex) + assertEquals(2, playback.nextFrameIndex) + playback.play(loop = true) + assertEquals(0, playback.nextFrameIndex) + assertEquals(0.5f, playback.interpolation, 0.001f) + playback.play() + playback.update(0.1f) + assertFalse(playback.playing) + assertEquals(2, playback.frameIndex) + assertEquals(0f, playback.interpolation) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/viewer/PreviewResourcesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/PreviewResourcesTest.kt new file mode 100644 index 000000000..6be487ada --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/PreviewResourcesTest.kt @@ -0,0 +1,86 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.Array as GdxArray +import com.badlogic.gdx.utils.Disposable +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PreviewResourcesTest { + private class Resource(val name: String, val disposed: MutableList) : Disposable { + override fun dispose() { disposed += name } + } + + private fun manager(disposed: MutableList) = AssetManager(FileHandleResolver(::FileHandle), false).apply { + setLoader(Resource::class.java, object : SynchronousAssetLoader>(FileHandleResolver(::FileHandle)) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): Resource { + check(fileName != "broken") { "Failed root load" } + return Resource(fileName, disposed) + } + + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): GdxArray>? = + if (fileName == "shared") null else GdxArray>().apply { + add(AssetDescriptor("shared", Resource::class.java)) + } + }) + } + + @Test + fun closesOwnedObjectsBeforeRootAndPreservesAnotherConsumersSharedDependency() { + val disposed = mutableListOf() + val assets = manager(disposed) + try { + assets.load("shared", Resource::class.java) + assets.finishLoading() + val preview = PreviewResources(assets) + preview.load("model", Resource::class.java) + preview.own(Disposable { + assertTrue(assets.isLoaded("model")) + disposed += "renderer" + }) + preview.close() + preview.close() + assertEquals(listOf("renderer", "model"), disposed) + assertFalse(assets.isLoaded("model")) + assertEquals(1, assets.getReferenceCount("shared")) + } finally { assets.dispose() } + } + + @Test + fun acquiresItsOwnReferenceToCachedRootAndCanBeReusedAfterClosing() { + val assets = manager(mutableListOf()) + try { + assets.load("model", Resource::class.java) + assets.finishLoading() + val preview = PreviewResources(assets) + assertSame(assets.get("model", Resource::class.java), preview.load("model", Resource::class.java)) + assertEquals(2, assets.getReferenceCount("model")) + assertThrows(IllegalStateException::class.java) { preview.load("other", Resource::class.java) } + preview.close() + assertEquals(1, assets.getReferenceCount("model")) + preview.load("other", Resource::class.java) + preview.close() + assertFalse(assets.isLoaded("other")) + assertTrue(assets.isLoaded("model")) + } finally { assets.dispose() } + } + + @Test + fun failedLoadCanBeClosedAndReusedAfterManagerRecovery() { + val assets = manager(mutableListOf()) + try { + val preview = PreviewResources(assets) + assertThrows(RuntimeException::class.java) { preview.load("broken", Resource::class.java) } + preview.close() + assets.clear() + preview.load("model", Resource::class.java) + preview.close() + assertEquals(0, assets.loadedAssets) + } finally { assets.dispose() } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/viewer/SoundPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/SoundPlaybackTest.kt new file mode 100644 index 000000000..5bb5c8869 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/SoundPlaybackTest.kt @@ -0,0 +1,126 @@ +package org.demoth.cake.viewer + +import com.badlogic.gdx.audio.Sound +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy + +class SoundPlaybackTest { + private class Audio { + var time = 0L + var nextId = 10L + val calls = mutableListOf() + val sound = Proxy.newProxyInstance(Sound::class.java.classLoader, arrayOf(Sound::class.java)) { _, method, args -> + calls += "${method.name}(${args?.joinToString().orEmpty()})" + if (method.name == "play" || method.name == "loop") nextId else null + } as Sound + fun playback(duration: Int? = 1000) = SoundPlayback(sound, duration) { time } + fun advance(ms: Long) { time += ms * 1_000_000L } + } + + @Test + fun pauseAndPlayResumeTheSameInstanceAndExcludePausedTime() { + val audio = Audio() + val playback = audio.playback() + playback.play() + audio.advance(400) + playback.pause() + audio.advance(5000) + playback.play() + assertEquals(listOf("play()", "pause(10)", "resume(10)"), audio.calls) + audio.advance(599) + playback.update() + assertTrue(playback.playing) + audio.advance(1) + playback.update() + assertFalse(playback.playing) + assertEquals("stop(10)", audio.calls.last()) + playback.play() + assertEquals("play()", audio.calls.last()) + } + + @Test + fun loopingAndToggleRetainPositionWhilePlaySelectsSinglePlayback() { + val audio = Audio() + val playback = audio.playback() + playback.play(loop = true) + audio.advance(2250) + playback.toggle() + assertFalse(playback.playing) + assertTrue(playback.looping) + audio.advance(3000) + playback.toggle() + assertTrue(playback.playing) + assertEquals(listOf("loop()", "pause(10)", "resume(10)"), audio.calls) + playback.play() + assertFalse(playback.looping) + assertEquals("setLooping(10, false)", audio.calls.last()) + audio.advance(750) + playback.update() + assertFalse(playback.playing) + } + + @Test + fun enablingLoopOnPausedPlaybackResumesWithoutRestarting() { + val audio = Audio() + val playback = audio.playback() + playback.play() + playback.pause() + playback.play(loop = true) + assertEquals(listOf("play()", "pause(10)", "setLooping(10, true)", "resume(10)"), audio.calls) + audio.advance(9000) + playback.update() + assertTrue(playback.playing) + assertTrue(playback.looping) + } + + @Test + fun replayAndStopOnlyReleaseTheOwnedInstanceAndResetState() { + val audio = Audio() + val playback = audio.playback() + playback.play(loop = true) + playback.pause() + audio.nextId = 11 + playback.replay() + assertEquals(listOf("loop()", "pause(10)", "stop(10)", "play()"), audio.calls) + assertTrue(playback.playing) + assertFalse(playback.looping) + playback.stop() + playback.stop() + assertEquals("stop(11)", audio.calls.last()) + assertEquals(1, audio.calls.count { it == "stop(11)" }) + assertFalse(playback.playing) + assertFalse(playback.looping) + } + + @Test + fun failedStartCanBeRetriedWithoutPausingOrStoppingAnInvalidInstance() { + val audio = Audio() + val playback = audio.playback() + audio.nextId = -1 + playback.play(loop = true) + playback.pause() + playback.stop() + assertFalse(playback.playing) + assertFalse(playback.looping) + assertEquals(listOf("loop()"), audio.calls) + audio.nextId = 12 + playback.toggle() + assertTrue(playback.playing) + assertEquals("play()", audio.calls.last()) + } + + @Test + fun unknownDurationStaysControllableUntilExplicitlyStopped() { + val audio = Audio() + val playback = audio.playback(null) + playback.play() + audio.advance(9000) + playback.update() + assertTrue(playback.playing) + playback.pause() + playback.play() + playback.stop() + assertEquals(listOf("play()", "pause(10)", "resume(10)", "stop(10)"), audio.calls) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolverTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerFileResolverTest.kt similarity index 52% rename from cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolverTest.kt rename to cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerFileResolverTest.kt index a6a636c98..f5ba3ce06 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerFileResolverTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerFileResolverTest.kt @@ -1,4 +1,4 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import com.badlogic.gdx.ApplicationListener import com.badlogic.gdx.Gdx @@ -6,8 +6,10 @@ import com.badlogic.gdx.backends.headless.HeadlessApplication import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test @@ -21,7 +23,7 @@ import java.nio.file.Files import java.nio.file.Path import java.util.LinkedHashMap -class ModelViewerFileResolverTest { +class ViewerFileResolverTest { @TempDir lateinit var temp: Path @@ -30,7 +32,7 @@ class ModelViewerFileResolverTest { fun resolvesFromOpenedFileDirectory() { val openedFile = createFile("viewer/tris.md2") val skin = createFile("viewer/skin.pcx") - val resolver = ModelViewerFileResolver(openedFile.absolutePath) + val resolver = ViewerFileResolver(openedFile.absolutePath) val resolved = resolver.resolve("skin.pcx") @@ -42,7 +44,7 @@ class ModelViewerFileResolverTest { fun resolvesCaseInsensitiveFromOpenedFileDirectory() { val openedFile = createFile("viewer/tris.md2") val skin = createFile("viewer/Skin.PCX") - val resolver = ModelViewerFileResolver(openedFile.absolutePath) + val resolver = ViewerFileResolver(openedFile.absolutePath) val resolved = resolver.resolve("skin.pcx") @@ -55,7 +57,7 @@ class ModelViewerFileResolverTest { fun resolvesByFileNameWhenDependencyUsesGameRelativePath() { val openedFile = createFile("viewer/tris.md2") val skin = createFile("viewer/grunt.pcx") - val resolver = ModelViewerFileResolver(openedFile.absolutePath) + val resolver = ViewerFileResolver(openedFile.absolutePath) val resolved = resolver.resolve("players/male/grunt.pcx") @@ -67,7 +69,7 @@ class ModelViewerFileResolverTest { fun fallsBackToBasedirBaseq2WhenProvided() { val openedFile = createFile("viewer/tris.md2") val baseSkin = createFile("baseq2/models/monsters/berserk/skin.pcx") - val resolver = ModelViewerFileResolver( + val resolver = ViewerFileResolver( openedFilePath = openedFile.absolutePath, basedir = temp.toString(), ) @@ -85,7 +87,7 @@ class ModelViewerFileResolverTest { temp.resolve("baseq2").resolve("pak0.pak"), linkedMapOf("models/monsters/berserk/skin.pcx" to "pak-skin".toByteArray(StandardCharsets.US_ASCII)), ) - val resolver = ModelViewerFileResolver( + val resolver = ViewerFileResolver( openedFilePath = openedFile.absolutePath, basedir = temp.toString(), ) @@ -103,7 +105,7 @@ class ModelViewerFileResolverTest { temp.resolve("baseq2").resolve("assets.pk3"), linkedMapOf("textures/e1u1/wall.wal" to "zip-wall".toByteArray(StandardCharsets.US_ASCII)), ) - val resolver = ModelViewerFileResolver( + val resolver = ViewerFileResolver( openedFilePath = openedFile.absolutePath, basedir = temp.toString(), ) @@ -118,7 +120,7 @@ class ModelViewerFileResolverTest { fun resolvesMapTextureFromParentFolder() { val openedMap = createFile("baseq2/maps/base1.bsp") val texture = createFile("baseq2/textures/e1u1/wall.wal") - val resolver = ModelViewerFileResolver(openedMap.absolutePath) + val resolver = ViewerFileResolver(openedMap.absolutePath) val resolved = resolver.resolve("textures/e1u1/wall.wal") @@ -129,13 +131,127 @@ class ModelViewerFileResolverTest { @Test fun returnsNullForMissingAsset() { val openedFile = createFile("viewer/tris.md2") - val resolver = ModelViewerFileResolver(openedFile.absolutePath) + val resolver = ViewerFileResolver(openedFile.absolutePath) val resolved = resolver.resolve("missing/asset/file.pcx") assertNull(resolved) } + @Test + fun reconfiguresLocalRootsAndAdjacentFileFallback() { + val first = createFile("first/models/tris.md2") + val second = createFile("second/models/tris.md2") + val firstTexture = createFile("first/textures/shared.wal") + val secondTexture = createFile("second/textures/shared.wal") + val firstSkin = createFile("first/models/skin.pcx") + val secondSkin = createFile("second/models/skin.pcx") + createFile("first/textures/old-only.wal") + val resolver = ViewerFileResolver(first.absolutePath) + + assertEquals(firstTexture.absolutePath, resolver.resolve("textures/shared.wal")!!.file().absolutePath) + assertEquals(firstSkin.absolutePath, resolver.resolve("players/male/skin.pcx")!!.file().absolutePath) + + resolver.configure(second.absolutePath) + + assertEquals(secondTexture.absolutePath, resolver.resolve("textures/shared.wal")!!.file().absolutePath) + assertEquals(secondSkin.absolutePath, resolver.resolve("players/male/skin.pcx")!!.file().absolutePath) + assertNull(resolver.resolve("textures/old-only.wal")) + } + + @Test + fun reconfiguresGameDataAndReleasesPreviousArchiveExtracts() { + val openedFile = createFile("viewer/models/tris.md2") + val path = "textures/shared.wal" + writePak(temp.resolve("first/baseq2/pak0.pak"), linkedMapOf(path to "first".toByteArray())) + writePak(temp.resolve("second/custom/pak0.pak"), linkedMapOf(path to "second".toByteArray())) + val resolver = ViewerFileResolver(openedFile.absolutePath, basedir = temp.resolve("first").toString()) + val firstExtract = resolver.resolve(path)!! + assertEquals("first", firstExtract.readString()) + + resolver.configure(openedFile.absolutePath, basedir = temp.resolve("second").toString(), gamemod = "custom") + + assertFalse(firstExtract.exists()) + val secondExtract = resolver.resolve(path)!! + assertEquals("second", secondExtract.readString()) + + resolver.configure(openedFile.absolutePath) + + assertFalse(secondExtract.exists()) + assertNull(resolver.resolve(path)) + } + + @Test + fun archiveContextPreservesLogicalPathsWithoutLooseOrProfileFallbacks() { + val openedFile = createFile("game/baseq2/models/tris.md2") + val loose = createFile("game/baseq2/textures/loose.wal") + createFile("game/baseq2/models/adjacent.pcx") + writePak(temp.resolve("game/baseq2/other.pak"), linkedMapOf("textures/other.wal" to byteArrayOf(1))) + val pak = temp.resolve("game/baseq2/selected.pak") + writePak(pak, linkedMapOf("models/thing/tris.md2" to "model".toByteArray(), + "models/thing/Skin.PCX" to "skin".toByteArray())) + val resolver = ViewerFileResolver(openedFile.absolutePath, basedir = temp.resolve("game").toString()) + + val entries = resolver.configureArchive(pak.toString()) + + assertEquals(listOf("models/thing/skin.pcx", "models/thing/tris.md2"), entries.map { it.normalizedPath }) + assertTrue(entries.all { it.source.containerPath == pak }) + assertEquals("model", resolver.resolve("models/thing/tris.md2")!!.readString()) + val skin = resolver.resolve("models/thing/skin.pcx")!! + assertEquals("skin", skin.readString()) + assertEquals(skin.path(), resolver.resolve("models/thing/skin.pcx")!!.path()) + assertNull(resolver.resolve("textures/loose.wal")) + assertNull(resolver.resolve("textures/other.wal")) + assertNull(resolver.resolve("players/male/adjacent.pcx")) + assertNull(resolver.resolve(loose.absolutePath)) + assertNotNull(resolver.resolve("com/badlogic/gdx/utils/lsans-15.fnt")) + resolver.configure(openedFile.absolutePath) + } + + @Test + fun changingArchivesAndReturningToLooseContextReleasesExtracts() { + val openedFile = createFile("viewer/models/tris.md2") + val loose = createFile("viewer/textures/shared.wal") + val path = "textures/shared.wal" + val first = temp.resolve("first.pak") + val second = temp.resolve("second.pak") + writePak(first, linkedMapOf(path to "first".toByteArray())) + writePak(second, linkedMapOf(path to "second".toByteArray())) + val resolver = ViewerFileResolver(openedFile.absolutePath) + resolver.configureArchive(first.toString()) + val firstExtract = resolver.resolve(path)!! + assertEquals("first", firstExtract.readString()) + + resolver.configureArchive(second.toString()) + + assertFalse(firstExtract.exists()) + val secondExtract = resolver.resolve(path)!! + assertEquals("second", secondExtract.readString()) + + resolver.configure(openedFile.absolutePath) + + assertFalse(secondExtract.exists()) + assertEquals(loose.absolutePath, resolver.resolve(path)!!.file().absolutePath) + } + + @Test + fun failedArchiveMountRetainsThePreviousContext() { + val openedFile = createFile("viewer/models/tris.md2") + val pak = temp.resolve("selected.pak") + writePak(pak, linkedMapOf("textures/shared.wal" to "selected".toByteArray())) + val resolver = ViewerFileResolver(openedFile.absolutePath) + resolver.configureArchive(pak.toString()) + val extracted = resolver.resolve("textures/shared.wal")!! + + assertThrows(IllegalArgumentException::class.java) { + resolver.configureArchive(temp.resolve("missing.pak").toString()) + } + + assertTrue(extracted.exists()) + assertEquals("selected", resolver.resolve("textures/shared.wal")!!.readString()) + resolver.configure(openedFile.absolutePath) + } + private fun createFile(relativePath: String): File { val file = temp.resolve(relativePath).toFile() file.parentFile.mkdirs() diff --git a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHintsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerStartupHintsTest.kt similarity index 82% rename from cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHintsTest.kt rename to cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerStartupHintsTest.kt index 59892e2fa..b93591e90 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/modelviewer/ModelViewerStartupHintsTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/viewer/ViewerStartupHintsTest.kt @@ -1,4 +1,4 @@ -package org.demoth.cake.modelviewer +package org.demoth.cake.viewer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -7,7 +7,7 @@ import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path -class ModelViewerStartupHintsTest { +class ViewerStartupHintsTest { @TempDir lateinit var temp: Path @@ -19,7 +19,7 @@ class ModelViewerStartupHintsTest { val openedFile = Files.createDirectories(temp.resolve("viewer")).resolve("tris.md2") Files.writeString(openedFile, "") - val hints = detectModelViewerStartupHints(openedFile.toString(), installRoot) + val hints = detectViewerStartupHints(openedFile.toString(), installRoot) assertEquals(installRoot.toString(), hints.basedir) assertNull(hints.gamemod) @@ -32,7 +32,7 @@ class ModelViewerStartupHintsTest { val openedFile = Files.createDirectories(installRoot.resolve("rogue/models/objects")).resolve("tris.md2") Files.writeString(openedFile, "") - val hints = detectModelViewerStartupHints(openedFile.toString(), temp) + val hints = detectViewerStartupHints(openedFile.toString(), temp) assertEquals(installRoot.toString(), hints.basedir) assertEquals("rogue", hints.gamemod) @@ -43,7 +43,7 @@ class ModelViewerStartupHintsTest { val openedFile = Files.createDirectories(temp.resolve("viewer")).resolve("tris.md2") Files.writeString(openedFile, "") - val hints = detectModelViewerStartupHints(openedFile.toString(), temp) + val hints = detectViewerStartupHints(openedFile.toString(), temp) assertNull(hints.basedir) assertNull(hints.gamemod) diff --git a/cake/desktop.gradle b/cake/desktop.gradle index 2fa59a5b7..554afeef2 100644 --- a/cake/desktop.gradle +++ b/cake/desktop.gradle @@ -4,12 +4,12 @@ application { } dependencies { implementation rootProject.libs.gdxControllersDesktop - implementation(rootProject.libs.gdxFreetypePlatform) { + runtimeOnly(rootProject.libs.gdxFreetypePlatform) { artifact { classifier = 'natives-desktop' } } - implementation(rootProject.libs.gdxPlatform) { + runtimeOnly(rootProject.libs.gdxPlatform) { artifact { classifier = 'natives-desktop' } diff --git a/cake/nativeimage.gradle b/cake/nativeimage.gradle index 314c9b26c..772c0fa52 100644 --- a/cake/nativeimage.gradle +++ b/cake/nativeimage.gradle @@ -18,6 +18,11 @@ graalvmNative { '.*(gdx-svmhelper-backend-lwjgl3|cake-).*\\.jar', 'META-INF/native-image/gdx-svmhelper/backend-lwjgl3/native-image.properties' ) + // The helper's scanners use removed GraalVM APIs; core supplies explicit metadata. + buildArgs.addAll( + '--exclude-config', '.*\\.jar', + 'META-INF/native-image/gdx-svmhelper/core/native-image.properties' + ) resources.autodetection { enabled = true restrictToProjectDependencies = false diff --git a/info/CodeState.md b/info/CodeState.md deleted file mode 100644 index de3aad1bf..000000000 --- a/info/CodeState.md +++ /dev/null @@ -1,16 +0,0 @@ -A high level (and subjective) overview of the code state at the moment of 2022. - -| Module | Item | State | Comment | -|--------|--------------------------|--------------------------|-----------------------------------------------------------------------------| -| Common | Exception/Error handling | Todo | It just doesn't exist | -| Common | Logging | Todo | Migrate to an existing framework | -| Common | File formats | Todo | Review and generify to support other formats | -| Common | Network communication | Todo | Review & restructure, migrate to modern approach | -| Common | Network protocol | [Ok](info/Networking.md) | | -| Common | Collision code (BSP) | Todo | Review & restructure, add support to qbsp2 | -| Client | 3D rendering/input/sound | WIP | Migrating to libGDX | -| Game | Entity framework | Not great | Not terrible though, other alternatives (like ECS) will also have drawbacks | -| Game | Character animation | WIP | Decouple from AI and Monster logic | -| Game | AI | WIP | Decouple from animation | -| Game | Weapon/Item logic | Todo | Review & restructure, not priority atm. | -| Server | Overall structure | WIP | Review & restructure, move away from files with functions to proper classes | diff --git a/info/ConfigStrings.md b/info/ConfigStrings.md index a5149c4e5..3d94795e8 100644 --- a/info/ConfigStrings.md +++ b/info/ConfigStrings.md @@ -2,7 +2,10 @@ Config strings are a general means of communication from the server to all connected clients. -Each config string can be at most MAX_QPATH(64) characters. +Configstring messages carry null-terminated strings; `MAX_QPATH` (64) is not a +universal limit on their values. Resource paths use path constraints, while +values such as the status-bar program can be longer and remain subject to the +message buffer limits. Config strings hold all the index strings, the lightstyles, and misc data like the sky definition and cdtrack. @@ -33,4 +36,4 @@ Overview of the config strings list array: MAX_CONFIGSTRINGS = (CS_GENERAL + MAX_GENERAL); // to be utilized in mods When the resources are registered by the game (sounds, models, textures, ...) their index is saved to the config string array. -This way only an integer is transmitted over the network instead of a long string of characters. \ No newline at end of file +This way only an integer is transmitted over the network instead of a long string of characters. diff --git a/info/Lights.md b/info/Lights.md index 8dcd0c9ea..dc66b042a 100644 --- a/info/Lights.md +++ b/info/Lights.md @@ -7,7 +7,8 @@ which is defined by the light style of the entity. Internally dynamic styles **values** are stored as a sequence of chars, representing light's magnitude with values between `a` (dark), `m` (normal value). and `z` (double bright). -So the light style `ma` will flicker between normal and dark every frame. +Lightstyle patterns advance every 100 ms. The style `ma` alternates between +normal and dark at those ticks, independently of the rendering frame rate. There are predefined light styles for common effects, see `jake2.game.GameSpawn.defineLightStyles`. diff --git a/info/Networking.md b/info/Networking.md index bd09c7271..d909ae7e7 100644 --- a/info/Networking.md +++ b/info/Networking.md @@ -16,9 +16,9 @@ see `jake2.qcommon.network.messages.ConnectionlessCommand` 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. -Check the `jake2.qcommon.network.Netchan.Transmit` - -TBD +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. ## Delta compression @@ -87,27 +87,3 @@ sequenceDiagram client->>server: MoveMessage end ``` - -# Further improvements - TODO - -### Api improvements - -Network package should expose only message classes and couple of source/sink classes/interfaces for them. -Nothing else is relevant and should be hidden with package private visibility. - -Example of the network api: - - // holding the network connection state, manages reliability, checksums, etc - class NetworkConnection { - // called multiple times per frame by client/server modules - void sendConnectionLessPacket(...) - - // called multiple times per frame by client/server/game modules, adds the message to the pending queue - void write(T) - - // called once every frame, packs all pending messages into packet and transmits over the network - void flush() - - // called every frame, unpacks the packet of incoming messages and validates - Collection receive() - } diff --git a/info/Overview.md b/info/Overview.md index 7bd49fb2c..6cf26a170 100644 --- a/info/Overview.md +++ b/info/Overview.md @@ -56,33 +56,28 @@ The structure design of the quake 2 is basically a set of global variables, and You can see functions changing state of some global static field very often. Apart from the problem of uncontrolled state scope and visibility, such approach makes jake2 very hard to redesign, refactor or introduce any substantial changes. -**state**: in progress - introduce necessary class structure for state +## Entity representation -## God object -https://en.wikipedia.org/wiki/God_object - -Quake uses `edict_t` & `GameEntity` (introduced in jake2) structures that have all possible fields for every scenario. -Thus, a debris gib, a monster and an ammo clip will have the same class. - -**state**: todo - split into components +`GameEntity` holds fields used by different entity kinds, including monsters, items +and debris. Existing character/animation experiments do not replace the general +entity representation. ## Behavior serialization During entity serialization (when the game is saved) along with the field values the behavior should be saved. To achieve that, so called adapters(`EntUseAdapter`, `EntTouchAdapter`, etc) are used. -These are "named" functions - an interface with String ID (see `jake2.game.SuperAdapter`). +These are "named" functions - an interface with String ID (see `jake2.game.adapters.SuperAdapter`). When such property is serialized - the Id is written to the file. During deserialization the id is read from the file and corresponding adapter is looked up in the registry. -This requires the registry to be build beforehand and kept consistent between save/load. +This requires the registry to be built beforehand and kept consistent between save/load. -**state**: ok ## Threading model -Quake2 is single threaded, so as Jake2. All IO operations and computation are done in the same thread. -Although the performance of jake2 is not a problem at the moment (mostly because it uses too little resources), -splitting io and non io operation will improve execution structure. -In future when jake can run multiple map instances in one process -introduction of non-blocking io can improve thread utilization even more. +Cake fullgame updates the client and embedded server on the libGDX application +thread. Multiple game instances run within that server process; this does not +imply concurrent mutation of engine state. UDP sockets use nonblocking +`DatagramChannel` reads. AssetManager can perform loader work on its loader thread, +so not all application I/O and computation happen on the simulation thread. -**state**: todo +The hosting lifecycle is documented in [the Cake guide](../cake/README.md). diff --git a/info/architecture.c4 b/info/architecture.c4 deleted file mode 100644 index 2fecf2e06..000000000 --- a/info/architecture.c4 +++ /dev/null @@ -1,64 +0,0 @@ -specification { - element System -} - -model { - System server { - // todo structure - // todo game initialization - } - System client { - System Network 'Network' 'Parsing & sending of msg' { - -> Cmd 'stuff text (insert text into console)' - } - System Cmd 'Cmd' 'Command subsystem' - System Cake 'Cake' 'main application process / entrypoint' - System Menu 'Menu' 'UI menu: Main/Options/etc' - System Console 'Console' 'quake style console' - System Game3dScreen 'Game3dScreen' 'Per instance level rendering/sound/input' { - System InputManager - System ClientEntityManager 'ClientEntityManager' 'Manages entity visibility both client/server' - System ConfigString - System RenderState - } - } - System q2Game { - // todo - } - - q2Game -> server {title "gameExports"} - server -> q2Game {title "gameImports"} - server -> client {title "network"} -} - -views { - view index { - include * - } - - view of client { - include * - } - - view of Game3dScreen { - include * - } - - dynamic view network_init { - title 'Client/server connection initialization' - - client -> server "getchallenge" - server -> client "challenge" {notes 'int challenge'} - client -> server "connect" - server -> client "client_connect" - client -> server "new" - server -> client "ServerDataMessage" - } -} - -/* -- high level system landscape(done) -- server system structure -- client system structure -- network initialization & runtime -*/ \ No newline at end of file diff --git a/info/cake-model-viewer.md b/info/cake-model-viewer.md deleted file mode 100644 index 68de001cc..000000000 --- a/info/cake-model-viewer.md +++ /dev/null @@ -1,67 +0,0 @@ -# Model viewer for Cake engine - -![screenshot](screenshots/md2-viewer.png) -![screenshot](screenshots/bsp-viewer.png) - -This small app can display .md2, .bsp, .pcx and .wal files (provided as an argument). -It is part of the `cake` module and has its own LWJGL3 entrypoint. - -### How to run -Run from IDE using `org.demoth.cake.lwjgl3.Lwjgl3ModelViewerLauncher.main` and pass a file path as the program argument. - -### How to build -Build the model viewer desktop app with `./gradlew :cake:cake-modelviewer:build`. - -### Native build -Alternatively, you can build a native (so you have easier time to associate with the files in OS) -`./gradlew :cake:cake-modelviewer:nativeBuild` -You will find the executable in `cake/cake-modelviewer/build/native/nativeCompile` - -## List of features - - renders .md2 model in the 1st animation frame with the first referenced skin (searches in the same folder as the .md2 file) - - can also render `.pcx` & `.wal` images - - grid & axes - - TODO: change skins - - TODO: render animation - - TODO: display information about opened file - like size, number of triangles, vertices, frames, etc... - -## How to associate .md2 files with Cake model viewer? - -### Linux instruction: - -1. Create a new MIME type description file. - Create a new file in the `~/.local/share/mime/packages` directory. - You can name it something like `quake-md2.xml.`: - -```xml - - - - Quake II Model - - - -``` - -Then update database `update-mime-database ~/.local/share/mime` - -2. Define the desktop entry in this file. -You can add an application file here `~/.local/share/applications/cake-md2-viewer.desktop`: - -``` - [Desktop Entry] - Version=1.0 - Name=Cake Model Viewer - Exec=/path/to/the/executable %f - Icon=quake - Terminal=false - Type=Application - MimeType=application/x-quake-md2 -``` - -Then update database `update-desktop-database ~/.local/share/applications` - -3. Associate the MIME Type with Your Application - - -Run `xdg-mime default cake-md2-viewer.desktop application/x-quake-md2` diff --git a/info/cake-viewer.md b/info/cake-viewer.md new file mode 100644 index 000000000..0a42862d8 --- /dev/null +++ b/info/cake-viewer.md @@ -0,0 +1,156 @@ +# Cake Viewer + +![screenshot](screenshots/md2-viewer.png) +![screenshot](screenshots/bsp-viewer.png) + +This small app can display .md2, .bsp, .pcx, .wal, .tga and .sp2 files, play .wav +sounds, and browse .pak archives (provided as an argument). +It is part of the `cake` module and has its own LWJGL3 entrypoint. + +Drop a resource file onto the viewer window to replace the current preview. +If several files are dropped, the first is opened. Switching stops sound playback +and resets animation and camera controls. Missing or unsupported files leave the +current preview open and show an error. A resource that fails during loading clears +the preview and shows an error; another file can still be dropped afterward. + +PAK archives open a folder tree on the left and the selected folder's contents on +the right in a wrapping grid, sorted with folders first. Select a folder in the +tree or double-click its tile to browse it. Click a file to show its logical path +and size in bytes in a fixed-height footer; long paths are ellipsized so selection +does not shift the grid. Visible PCX, WAL and TGA tiles load image previews progressively; +unavailable images show a placeholder. Double-click an image to fit it to the +preview area, preserving its aspect ratio and enlarging small images. Use the +wheel over the pane to zoom around the pointer and left-drag to pan. Resizing +retains relative zoom and pan; reopening an image restores the initial fit. +"Back to folder" restores the grid +and its scroll position. Loaded thumbnails stay available while scrolling in the +same folder and across full-preview/Back navigation. Leaving the folder or archive +releases them; there is no persistent thumbnail cache. Preview tiles use plain +images with a flat hover/selection highlight. Browsing reads archive metadata without extraction; +visible thumbnails and full previews materialize resources on demand. A failed +resource preview shows an error and reopens the archive at its root folder. + +Click a WAV tile to play once while keeping the folder grid visible. Double-click +to show duration and Play/Pause/Loop/Stop controls without restarting playback. +Play starts or resumes single playback, Pause holds position, Loop starts or +resumes repeated playback, and Stop resets. Space toggles pause/resume while +retaining loop mode; S stops. Clicking the tile again restarts single playback. +Selecting another tile, Back to folder, changing +folders, or opening another resource stops playback and unloads the sound. + +Click an SP2 tile to play its animation once in the tile. Double-click to open +the larger preview without restarting playback. Like images, it initially fits +the pane and supports pointer-anchored wheel zoom and left-drag pan. Frames are +centred on a fixed canvas with a shared pixel scale; frame changes retain zoom +and pan. The same frame sizing is used for tile animation. It shows frame index/count and +dimensions, with Previous/Next, Play, Pause, Loop and Stop controls. Play resumes +or restarts a completed animation; Stop resets to the first frame. Frame stepping +pauses playback. Space toggles play/pause and Left/Right steps frames. Back or +changing selection releases the sprite and its frame textures. + +Double-click an MD2 tile to view its first frame, automatically centred and framed +in the preview pane. Drag with the left mouse button to orbit around the model; +use the wheel over the pane to zoom. Resizing adjusts the preview resolution and +framing. The preview shows frame index/count, the stored frame name, and skin +texture count. MD2 and sprite previews share Play/Pause/Loop/Stop and Previous/Next +controls: Play resumes or restarts a completed animation, Pause holds its current +pose, Loop repeats, Stop resets to frame one, and stepping pauses and wraps. +The skin selector shows loaded skin indices and filenames; changing it preserves +animation and camera position. Single-skin models show a disabled selector, and +generated fallback textures are labelled "Default skin". Selection covers the +first 12 skins supported by the renderer. + +Double-click a BSP tile to inspect the world and static brush geometry. Click the +pane to focus it, use WASD to fly and Q/E to move down/up, and left-drag to look +around in place. Clicking outside the pane releases movement focus. The camera +starts at player eye height, preferring an unnamed level start over named return +entrances; maps without a spawn start at the world bounds' centre. Movement has +no collision. The pane shows surface and total inline brush model counts. +Trigger brushes and area portals are hidden in both archive and loose previews. +The preview has no gameplay entities or sky rendering. + +### How to run +Run from IDE using `org.demoth.cake.lwjgl3.Lwjgl3ViewerLauncher.main` and pass a file path as the program argument. + +### How to build +Build the viewer desktop app with `./gradlew :cake:cake-viewer:build`. + +### Native build +Alternatively, you can build a native (so you have easier time to associate with the files in OS) +`./gradlew -PenableGraalNative=true :cake:cake-viewer:nativeCompile` +You will find the executable in `cake/cake-viewer/build/native/nativeCompile` + +## List of features + +- Renders `.md2` models with frame stepping and interpolated animation playback. +- Resolves referenced skins using the opened file's folder and parent, with detected + game-data fallback and an adjacent-file fallback for game-relative skin paths. + Models can render with a default skin when their referenced skins are missing. +- Renders `.bsp` maps and `.pcx` / `.wal` / `.tga` images. +- BSP previews reuse the engine world renderer for opaque and translucent world + surfaces, with all surfaces enabled for free-flight inspection, static light + styles at full intensity, and no gameplay dynamic lights. +- Displays `.sp2` sprites at each frame's declared dimensions, resolving + referenced images through the same resource lookup as model skins. +- Plays `.wav` sounds, including Quake II's 8-bit PCM files. +- Uses the shared engine skin for a Scene2D information panel showing the filename, + image dimensions, current/total SP2 frames with frame dimensions, MD2 frame + and loaded skin texture counts (including the fallback texture when used), + sound duration in seconds (or "unknown" when unavailable), and BSP world surface + and inline brush model counts from the loaded render data. +- Displays a grid and axes for models and maps. + +For MD2 models, press Space to toggle animation playback and Left/Right to step +frames. Playback runs once by default; archive previews also offer explicit Loop. +MD2 animation interpolates smoothly between frames, while stepping selects an +exact frame. These playback rules also apply to loose model previews. Press +Escape to exit. + +Loose SP2 sprites start paused on the first frame. Press Space to play/pause a +single animation at 10 frames per second, or Left/Right to step frames with +wraparound. Archive previews also offer explicit looping. SP2 files do not +specify a playback rate. Controls are also shown in the window. + +WAV files play once on opening. Press Space to pause/resume, S to stop, and Escape to +close. These controls are also shown in the window. + +## How to associate .md2 files with Cake Viewer? + +### Linux instruction: + +1. Create a new MIME type description file. + Create a new file in the `~/.local/share/mime/packages` directory. + You can name it something like `quake-md2.xml.`: + +```xml + + + + Quake II Model + + + +``` + +Then update database `update-mime-database ~/.local/share/mime` + +2. Define the desktop entry in this file. +You can add an application file here `~/.local/share/applications/cake-viewer.desktop`: + +``` + [Desktop Entry] + Version=1.0 + Name=Cake Viewer + Exec=/path/to/the/executable %f + Icon=quake + Terminal=false + Type=Application + MimeType=application/x-quake-md2 +``` + +Then update database `update-desktop-database ~/.local/share/applications` + +3. Associate the MIME Type with Your Application + + +Run `xdg-mime default cake-viewer.desktop application/x-quake-md2` diff --git a/info/new-commands-cvars.md b/info/new-commands-cvars.md index 6e1049497..fc2032587 100644 --- a/info/new-commands-cvars.md +++ b/info/new-commands-cvars.md @@ -1,8 +1,4 @@ -# New cvars -_Add new cvars here_ - -# New console commands -_Add new console commands here_ +# Additional console commands * `listmaps` - prints list of maps from maps.lst @@ -13,5 +9,5 @@ _Add new console commands here_ ### Multi-instance * `games` - prints list of game names (Multiinstance feature) - * `spamgames n` - creates `n` games with `base1` map (Used for testing, to be removed in future) + * `spamgames n` - creates `n` games with `base1` map (debug/stress command) * `gamemap ` - puts the client with index `` to a separate game with a `` diff --git a/info/planning/cake-menus-todo.md b/info/planning/cake-menus-todo.md deleted file mode 100644 index 696eb9b65..000000000 --- a/info/planning/cake-menus-todo.md +++ /dev/null @@ -1,573 +0,0 @@ -# Cake Menus TODO - -## Goal -- Make Cake usable from first launch without manual config-file edits. -- Prioritize thin-client flow (join remote servers) before local hosting/singleplayer. -- Keep v1 implementation intentionally small and migration-friendly. - -## Scope - -### In Scope (now) -- Thin client usability from startup to joining a server. -- Game profile setup and selection (`id`, `basedir`, optional `gamemod`). -- Main menu + options + multiplayer/join/player setup screens. -- First-run autodetect + guided setup fallback. - -### Out of Scope (now) -- Singleplayer runtime. -- Hosting local server / local coop runtime. - -### Future (tracked) -- Add an integrated wrapper module that combines `server + game + cake`. -- This wrapper will enable: - - Singleplayer mode. - - Local coop hosting mode. - -## Key Findings (codebase) -- Main menu has a minimum shell in `cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt`: - - current profile display + selector - - `Singleplayer` and `Host Game` disabled placeholders -- Cake startup resolves selected/default profile and applies it to resolver in - `cake/core/src/main/kotlin/org/demoth/cake/Cake.kt` (`applyGameProfile` -> `fileResolver.basedir/gamemod`). -- Shared FS already has Steam autodetect logic in `qcommon/src/main/java/jake2/qcommon/filesystem/FS.java` (`autodetectBasedir`). -- Cake already exposes VFS diagnostics and mount/index metadata via `VfsDebugCommands`, which can later power an asset explorer UI. - -## UX Targets -- First launch with Steam install: auto-detect content and land in usable menu flow. -- First launch without detected content: guided setup to choose `basedir`. -- Switching mod/profile should not require manual file edits. -- Join remote multiplayer server entirely from UI. -- Profile-local writable state lives under `.cake/`. - -## Decisions (2026-03-06) -- `profiles.json` will contain: - - schema `version` - - `selectedProfileId` - - `profiles[]` -- Minimal profile shape for now: - - `id` (alphanumeric only, unique, used as folder name) - - `basedir` - - optional `gamemod` -- No separate display name yet (can be added later). -- Keep implementation simple: - - small Kotlin data classes (single-line style where practical) - - Jackson serialization/deserialization -- Near-term install model supports one game installation root (`basedir`) per profile. -- Future multi-install support will extend profile schema (additional roots) when needed. - -## Screen Plan (thin client) -- Startup Gate - - Resolve active profile. - - Validate content availability. - - Route to main menu or first-run setup. -- First-Run Setup - - Auto-detect result + manual browse/edit path. - - Validate and save profile. -- Main Menu - - `Disconnect` (enabled only when connected/in-game) - - `Multiplayer` (enabled) - - `Options` (enabled) - - `Game Configuration` (enabled) - - `Singleplayer` (disabled, future) - - `Host Game` (disabled, future) - - `Exit` (enabled) -- Multiplayer - - `Join Game` (enabled) - - `Player Setup` (enabled) - - `Host Game` (disabled, future) -- Join Game - - Manual address + server discovery list. -- Player Setup - - Name/model/skin/handedness/rate preview workflow. -- Options - - Video, audio, controls/general sections. - -## UI Behavior Rules (current phase) -- Keep `Singleplayer` and `Host Game` visible but disabled. -- Disabled entries should clearly show "future/not available in thin-client mode". -- Avoid dead buttons: every enabled entry must navigate or perform a valid action. -- Game profile switching is allowed only while disconnected (menu/offline state). -- During active connection/game: - - profile selector UI is disabled/read-only - - show short hint that profile changes require disconnect first. - -## Delivery Plan - -### Phase 1: Startup/Foundation -- Introduce launcher-level game profile model (separate from runtime `GameConfiguration`): - - `profiles.json` root model with `version`, `selectedProfileId`, `profiles` - - `GameProfile(id, basedir, gamemod?)` -- Implement startup resolver: - - CLI overrides (if any) > saved profile > autodetect > setup screen. -- Wire Steam autodetect into Cake startup path. -- Remove hard dependency on `System.getProperty("basedir")` for normal startup flow. -- Route writable profile data into `.cake//...` (configs/screenshots/saves). - -### Phase 2: Navigation Shell -- Replace placeholder main menu with structured navigation. -- Add `Game Configuration` screen for profile view/edit/select. -- Add `Multiplayer` hub with thin-client-safe actions. -- Add disabled `Singleplayer`/`Host Game` entries. - -### Phase 3: Join + Player Setup -- Implement `Join Game` screen flow. -- Implement `Player Setup` screen. -- Ensure profile context (basedir/mod) is respected by asset resolution and UI state. - -### Phase 4: Options -- Implement options categories with high-impact settings first: - - Video (mode/fullscreen/vsync/basic rendering toggles) - - Audio (master/music) - - General/controls basics. - -### Phase 5: Future Prep -- Mark integrated wrapper module as separate epic. -- Keep interfaces between launcher config and runtime clean to support dual modes later: - - thin-client mode - - integrated mode (future). - -## Next Implementation Steps (immediate) -1. Profile schema + persistence -- Finalize `profiles.json` v1 models and Jackson mapping. -- Ensure default bootstrap behavior: - - if no file exists, create default profile record - - persist `selectedProfileId` -- Validate `id` (alphanumeric), `basedir` existence, and `gamemod` directory-token safety. - -2. Startup integration -- Load selected profile during Cake boot. -- Resolve effective game configuration from selected profile (`basedir`, optional `gamemod`). -- Use existing Steam autodetect as fallback when profile file is absent or invalid. - -3. Profile-scoped writable paths -- Introduce profile data root helper (`.cake/`). -- Point config/screenshot/save outputs to profile root. - -4. UI increment (minimum usable shell) -- Main menu: add current profile switch entry + multiplayer/options/exit. -- Keep singleplayer/host visible but disabled with thin-client note. -- Add basic profile list/select screen before full editor UX. - -5. Tests -- Unit tests for profile read/write/default bootstrap/validation. -- Startup resolver tests for precedence and fallback paths. - -## Progress (2026-03-06) -- Done: profile schema + persistence. - - Added `.cake/profiles.json` with `version`, `selectedProfileId`, `profiles[]`. - - Added profile validation (`id`, `basedir`, `gamemod`). -- Done: startup integration. - - Cake now loads selected profile on startup. - - If no profile exists, Cake bootstraps a default profile (`id=default`) with basedir resolution: - - JVM `basedir` property - - Steam autodetect paths - - fallback `"."` -- Done: profile-scoped writable paths. - - Screenshots and save metadata now write under `.cake//...`. -- Done: tests for new profile store and updated save store behavior. -- Done: minimum UI increment. - - Main menu now shows current profile and allows profile selection from existing profiles. - - Singleplayer and Host Game are visible but disabled as future thin-client-incompatible modes. -- Done: dedicated game configuration/profile edit stage. - - Clicking current profile now opens a dedicated profile editor stage. - - Added two-pane layout: profile list (left) and editable profile form (right). - - Added `Create New Profile`, `Autodetect`, `Save`, and `Back` actions. -- Next: first-run setup/guided profile bootstrap flow. -- Done: `Disconnect` main menu action and connection-aware menu state. -- Done: profile switching locked to disconnected state (UI + command guard). -- Verify/keep: profile `basedir` seeds VFS via `applyGameProfile` (`fileResolver.basedir` -> `CakeVfsAssetSource.configure`). - -## Profile Edit Refinements (2026-03-07) -- Layout adjustments: - - left panel should be packed/minimal width (no fixed wide column), - - right panel should consume remaining horizontal space. -- Move `Back` button to the left panel under the profile list/actions. -- Keep right-side form simple and stacked (`label` then `textfield` rows). -- Implement explicit checked/toggled state for the selected profile button in the list. -- `Autodetect` should be a no-op when detection fails (do not overwrite fields). - -## Runtime Content Style Refactor (2026-03-26) -- Pull HUD style ownership out of `Game3dScreen` and up into `Cake`. - - `Game3dScreen` should receive a ready-to-use HUD/content style and should not need to know `gameName`. -- Keep runtime content-style switching semantics: - - effective style follows selected profile while disconnected, - - effective style can switch to server-provided mod context while connected, - - but style objects should be recreated only when the effective style key actually changes. -- Menu stages will switch style by being rebuilt, not by attempting in-place Scene2D skin mutation. - - this is acceptable because current menu state is controller-owned and purely local widget state is not valuable enough to preserve. -- Console remains explicitly on engine-owned styling and is out of scope for content-style switching. -- Profile editor behavior change: - - selecting a profile in the list only loads it into the editor, - - applying/switching the active profile is now a separate explicit action, - - this avoids background/style churn on every list click. -- Progress: - - explicit profile apply is implemented, - - HUD style ownership is moved from `Game3dScreen` to `Cake`, - - runtime style reuse is keyed by effective resolver context so identical reconnects do not recreate the same HUD style. - - `MainMenuStage` now consumes the shared content style and is rebuilt when the effective style changes. - - `MultiplayerMenuStage` and `OptionsMenuStage` now use the shared content style as well, including styled `Back` buttons. - - `JoinGameStage`, `PlayerSetupStage`, `ProfileEditStage`, and `OptionsSectionStage` now use the shared content style for labels/buttons/text fields; engine widget chrome remains for the more complex inputs. - - `PlayerSetupStage` select boxes now use the shared content style for the closed box and popup list/scrollpane, reusing conchars borders and content font without a dedicated dropdown arrow yet. - - `ProfileEditStage` profile list and scroll pane now use the shared content style, so the remaining editor widget chrome is no longer split between engine and game style. - - content-styled buttons now use conchars-based box chrome instead of only swapping the font. - - styled hub menus now use shared button padding/min width and tighter row spacing to better fit the conchars button chrome. - - submenu enter/exit sounds now come from the shared content style and are triggered by controller-owned menu-depth transitions. - - button hover now uses green text plus `menu2.wav`, and hover state is reset on click/menu transitions. - - Missing game-styled widget chrome is tracked explicitly: - - none for the currently styled menu screens; future gaps are expected to come from newly introduced widget types rather than the existing menu set. - -## Menu Messaging Refactor (2026-03-09) -- Introduced a typed menu messaging layer to decouple stages from `Cake` callback wiring: - - `MenuIntent` (UI -> app actions) - - `MenuSignal` + `MenuStateSnapshot` (app -> UI state) - - `MenuEventBus` (in-memory FIFO, frame-drained) -- Added `MenuController` to process intents and produce menu state snapshots. -- Migrated both `MainMenuStage` and `ProfileEditStage` to bus-driven interaction/state. -- Removed stage-specific lambda callback coupling for main/profile screens. -- Added tests for: - - bus FIFO/limit/state behavior - - controller intent-to-state transitions for profile flow. - -## Profile Layout Stabilization (2026-03-09) -- Made profile screen column sizing explicit: - - left panel remains packed/content-driven, - - right panel expands to take remaining width. -- Updated profile editor form rows so text fields use `growX + fillX`. -- Wrapped the profile list into a bounded vertical `ScrollPane` to prevent long lists from distorting the stage layout. - -## Reference Notes (for ideas) -- Yamagi: classic robust multiplayer split (`join/start/player setup`). -- Q2Pro: strong data-driven menu definitions (`q2pro.menu`) and flexible menu scripting. -- KMQuake2: practical options category split and useful join compatibility controls. - -## Video Mode Bootstrap (2026-03-21) -- Goal: - - Make `vid_*` cvars actually control launcher-owned window settings for the next app start. - - Keep this phase restart-based; no live window recreation is required yet. -- Decision: - - Do not move full VFS/libGDX asset initialization before `Cake()`. - - Move only a small startup bootstrap earlier: - - `Cmd.Init()` - - `Cvar.Init()` - - register aliases - - register `vid_*` cvars with defaults - - resolve active Cake profile - - read and execute `.cake//config.cfg` - - The launcher then reads `vid_*` from `Cvar` and builds `Lwjgl3ApplicationConfiguration` before creating `Cake()`. -- Initial `vid_*` scope: - - `vid_fullscreen` - - `vid_width` - - `vid_height` - - `vid_vsync` -- Follow-up implementation notes: - - Pass a small startup context into `Cake` so profile/config startup work is not duplicated. - - `CVAR_LATCH` is acceptable for restart-required client settings, but current persistence must be adjusted: - - when `latched_string` exists, profile config save should persist that pending value - - next launch should use that pending value during early bootstrap - - Full game-data VFS can remain Cake-owned and initialized later through `CakeFileResolver`. -- Progress: - - Added shared Cake startup bootstrap for: - - command/cvar initialization - - common Cake cvar registration - - early `ClientBindings` creation so `bind` / `unbindall` are available during config execution - - selected-profile resolution - - profile config execution before launcher window creation - - Added first real video mode cvars: - - `vid_fullscreen` - - `vid_width` - - `vid_height` - - `vid_vsync` - - Updated the LWJGL3 launcher to read bootstrapped `vid_*` values before constructing `Cake()`. - - Updated archive persistence so restart-latched `vid_*` values are written using their pending value. - - Added regression coverage for: - - idle `vid_*` latch behavior - - archive write preferring `latched_string` - -## Video Apply Paths (future) -- Phase-1 path: - - Save `vid_*` changes in Options. - - Mark them as pending restart. - - Apply them on the next app launch through the early startup bootstrap. -- Future no-restart path candidates: - - In-process live switch: - - attempt runtime `Gdx.graphics` mode changes directly - - likely viable for fullscreen/windowed switch, size changes, vsync - - requires explicit success/failure handling and rollback to prior mode on failure - - Controlled app restart through launcher: - - Cake requests restart after saving pending `vid_*` - - launcher catches a typed restart signal/exception - - launcher creates a new `Lwjgl3ApplicationConfiguration` from current cvars and restarts Cake - - if restart originated from Options, launcher can pass a resume target so Cake returns to the same menu section - - Confirmation/rollback flow: - - after applying a new video mode, show a timed confirmation dialog - - if user confirms, keep the new mode - - if user cancels or timeout expires, restore the previous mode/settings and return to a known-safe menu state -- Open questions: - - none for the phase-1 restart-based implementation - - future no-restart apply will need a concrete launcher-to-Cake restart contract and a UI confirmation flow - -## Definition of Done (this track) -- New user can start Cake and join a remote server without editing files. -- Autodetect/setup handles missing content gracefully. -- Singleplayer/host paths are disabled clearly (not broken). -- Architecture has an explicit extension point for future integrated wrapper module. - -## Profile Background (2026-03-14) -- Goal: - - Show an optional profile-specific background image while no `Game3dScreen` is active. - - Use `pics/conback.pcx` so mod overrides naturally brand the current profile. -- Decisions: - - Background load/reload is owned by `Cake`, not by `GameConfiguration`. - - `applyGameProfile(...)` is the single load/reload hook for both: - - startup selected profile - - runtime profile switching while disconnected - - Missing `conback.pcx` is non-fatal and silent. - - Render only while `game3dScreen == null`. -- Progress: - - Added Cake-owned background texture/batch state. - - Added background reload on `applyGameProfile(...)`. - - Added background rendering in the main loop after clear and before menu/console. - -## Client Stability Track (2026-03-13) - -### Goal -- Make Cake survive missing or malformed client resources without process-level crashes. -- Replace legacy `Com.Error`-driven client flow in the `cake/` module with typed Kotlin exceptions and explicit recovery boundaries. -- Make missing-resource behavior configurable: - - strict mode: fail fast with a typed `MissingResourceException` - - fallback mode: use engine-owned placeholder assets where supported - -### Current Findings (codebase) -- `CakeFileResolver.resolve(...)` currently returns `null` for missing assets. -- LibGDX synchronous loaders in Cake declare non-null `FileHandle` parameters, so a missing resolver result becomes a generic Kotlin NPE instead of a domain error. -- The main offenders are: - - `cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt` - - `cake/core/src/main/kotlin/org/demoth/cake/assets/CakeTextureLoaders.kt` - - `cake/core/src/main/kotlin/org/demoth/cake/assets/Md2ModelLoader.kt` -- Missing-resource handling is inconsistent today: - - some call sites pre-check `assetManager.fileHandleResolver.resolve(path) == null` - - some call `tryAcquireAsset(...)` and absorb `GdxRuntimeException` - - some paths still rely on generic top-level exception recovery in `Cake` -- Cake already has a generic drop-to-console boundary in `Cake.render()`, but it is still coupled to legacy `Com.Error`/`longjmpException` behavior. -- Cake still contains at least one direct `Com.Error` call in network/message handling (`Server disconnected` path in `Cake.kt`). - -### Reference Notes (verified) -- Yamagi/Q2Pro still use `ERR_DROP` / `ERR_DISCONNECT` as a client-frame recovery mechanism, then disconnect back to a stable console/client shell. -- Reference renderers also keep engine-owned fallback textures instead of crashing on every missing texture: - - Yamagi GL1 keeps `r_notexture` - - Q2Pro reuses placeholder images and keeps a notion of missing-image diagnostics -- The Cake equivalent should preserve the same high-level behavior, but using typed exceptions and Kotlin recovery boundaries instead of legacy `Com.Error` in the client module. - -### Decisions -- Introduce a domain exception: `MissingResourceException`. -- `cake/` must stop using `Com.Error` for expected client-side resource/runtime recovery. -- Missing resources must be handled generically at two boundaries: - - startup / connection / precache / screen init - - per-frame `Game3dScreen.render` -- Recovery target for non-fatal client failures is: - - disconnect client state if needed - - leave the app running - - return to console/menu shell - - print a concise warning/error describing the missing resource and phase -- Add a new cvar: `fs_debug_loaders`. - -### `MissingResourceException` Shape -- Add a typed runtime exception in Cake asset/runtime code: - - `path: String` - - `resourceKind: ResourceKind` - - `phase: String` (`resolver`, `loader`, `precache`, `render`, `connection`) - - optional `cause: Throwable` -- `ResourceKind` should be an enum with explicit resolver policy: - - `PCX("_missing.pcx", tolerableMissing = false)` - - `WAL("_missing.wal", tolerableMissing = false)` - - `MD2("_missing.md2", tolerableMissing = false)` - - `SP2("_missing.sp2", tolerableMissing = false)` - - `SOUND(null, tolerableMissing = true)` -- `ResourceKind` exists because fallback routing must be deterministic and not re-inferred ad hoc from string paths at every call site. -- Engine assets are assumed to guarantee the configured fallback files exist. -- Missing fallback-content handling is intentionally out of scope for this track. -- Use it for: - - resolver misses that should be treated as fatal in strict mode - - loader dependency misses - - sound requests where missing content is explicitly tolerated - -### `fs_debug_loaders` Policy -- Add `fs_debug_loaders` as the loader-policy cvar. -- Proposed semantics: - - `0`: strict mode - - `1`: fallback mode for supported resource classes -- Strict mode behavior: - - throw `MissingResourceException` for non-tolerable resource kinds - - return `null` for tolerable missing kinds (for example `SOUND`) - - let generic Cake/Game3dScreen boundaries disconnect to console -- Fallback mode behavior: - - substitute engine-owned placeholder assets where a type-specific fallback exists - - return `null` for tolerable missing kinds with no fallback asset - - emit `Com.Warn` once per missing path/resource kind - - continue execution unless fallback cannot be provided -- Initial fallback scope: - - textures -> `_missing.pcx`, `_missing.wal` - - md2 models -> `_missing.md2` - - sprite -> `_missing.sp2` - -- Deferred fallback scope: - - cinematics -- Fallback assets must be engine-owned, mounted predictably, and not depend on mod content being complete. -- Sky fallback is removed from scope: - - `sky/.sky` is a synthetic key, not a real file format fallback target - - missing sky side textures should be handled through normal PCX fallback instead - -### Resolver / Loader Rules -- Stop treating `null` `FileHandle` as a normal resolver contract inside Cake loaders. -- Implement the policy directly in `CakeFileResolver.resolve(...)`. -- Extract current raw lookup logic into `internal fun tryResolve(...)`. -- `resolve(...)` should: - - call `tryResolve(...)` - - if found, return the real handle - - if missing, infer `ResourceKind` from the requested extension for supported types - - in strict mode, throw `MissingResourceException` for non-tolerable kinds - - in fallback mode, return `Gdx.files.internal()` when the kind provides a fallback - - for tolerable-missing kinds (currently `SOUND`), warn and return `null` - - for unsupported/out-of-scope types, keep current behavior unless and until they are explicitly classified -- Warning policy: - - keep a small in-memory `Set>` in the resolver - - warn only when the resolver makes a final policy decision for a missing resource - - do not warn on every raw probe/lookup attempt -- Rules for loaders: - - loaders must validate the incoming file/dependency path before dereferencing - - loaders must not throw generic `NullPointerException` for missing input - - loaders should wrap malformed-content errors with path/context when practical -- Rules for `GameConfiguration` / asset acquisition: - - centralize missing-resource mapping in one asset-acquisition path - - remove ad hoc `resolve(path) == null` pre-checks from callers where possible - - keep `tryAcquireAsset(...)` domain-aware instead of relying on generic `GdxRuntimeException` handling - -### Recovery Boundaries -- Add explicit generic handlers around: - - `Game3dScreen` initialization / precache path - - `Game3dScreen.render` - - connection/startup transitions in `Cake` -- Boundary behavior: - - `MissingResourceException` -> warn, disconnect, return to console - - current scope is intentionally narrow: only missing-resource failures with supported fallback policy participate in this path - - other asset/runtime exceptions remain out of scope for this track and should still surface normally -- Remove the remaining direct `Com.Error` usage from Cake and replace it with normal Kotlin control flow plus the shared recovery boundary. -- Cleanup behavior after a handled client-side missing-resource failure: - - dispose / clear the active `Game3dScreen` - - leave the application process alive - - show the console - - rely on the already emitted warning/error for user-visible diagnosis - -### Implementation Plan -1. Exception model -- Add `MissingResourceException` - -2. Loader policy -- Add `fs_debug_loaders`. -- Add `ResourceKind` with: - - fallback asset path - - tolerable-missing flag -- Put the missing-resource policy directly into `CakeFileResolver.resolve(...)`. -- Add resolver-local warn-once bookkeeping keyed by `(requestedPath, resourceKind)`. -- Postpone loader signature changes unless the resolver-level fix still leaves nullability gaps. - -3. Resolver refactor -- Extract the existing raw lookup flow into `tryResolve(...)`. -- Keep asset probing order unchanged: - - VFS game data - - classpath fallback - - internal engine assets -- Add extension-to-`ResourceKind` inference for the supported types of this track. -- Resolver outcomes become: - - real asset file - - internal fallback asset file - - `null` for explicitly tolerable missing kinds - - `MissingResourceException` for non-tolerable unresolved kinds in strict mode - -4. Loader hardening -- Update `PcxLoader`, `WalLoader`, `Md2Loader`, and other custom loaders to: - - reject missing input with `MissingResourceException` - - use placeholder assets when policy allows - - preserve useful path/cause information - -5. Asset acquisition cleanup -- Refactor `GameConfiguration` and nearby callers to stop scattering `resolve(path) == null` checks. -- Route all asset loads through a common path that understands `MissingResourceException` and fallback policy. -- Preserve original logical asset keys for AssetManager ownership/refcounting: - - request path remains the original missing asset path - - resolver may physically return a fallback file handle behind that key - -6. Recovery boundaries -- Move from the current one-off Cake exception handling to explicit reusable handlers for: - - startup / connection / precache - - in-game render -- Replace the last Cake `Com.Error` usage with normal exception-driven disconnect flow. -- Cleanup target for handled missing-resource failures: - - dispose active gameplay screen state - - drop back to console - - do not terminate the app - -7. Placeholder content -- Add/verify `_missing.pcx`, `_missing.md2`, and others -- Ensure they can load without depending on optional mod assets. -- Fallback assets live in Cake engine's asset folder. -- Required initial placeholder set: - - `_missing.pcx` - - `_missing.wal` - - `_missing.md2` - - `_missing.sp2` -- `_missing.sp2` is a real sprite file stored in engine assets and points at `_missing.pcx`. - -8. Tests -- Add tests for strict/fallback resolver behavior. -- Add loader tests proving missing assets raise `MissingResourceException` instead of NPE. -- Add tests proving fallback mode returns placeholder assets for supported types. -- Add tests for generic boundary behavior: - - startup/precache failure drops to console - - render-time failure drops to console - - app process remains alive - -### Required Design Details Before Implementation -- `fs_debug_loaders` stays boolean-like (`0/1`) -- `ResourceKind` stays explicit and owns: - - fallback mapping for supported asset classes - - tolerable-missing policy -- Missing-resource policy is resolver-owned for this track. -- Original logical asset keys are preserved even when the resolver supplies a fallback file handle. -- Malformed assets are out of scope for this resolver-first track. -- Sounds are not fallback-backed, but are classified as tolerable missing via `ResourceKind.SOUND`. -- Cinematics remain out of scope. - -### Verification Checklist -- Verify there are no remaining direct `Com.Error` calls in `cake/`. -- Verify no Cake custom loader can produce a generic Kotlin NPE for missing file input. -- Verify connecting to a server with a missing texture/model drops cleanly to console instead of terminating the app. -- Verify render-time missing assets follow the same recovery path as startup/precache misses. -- Verify fallback mode logs the missing path and actual placeholder used. -- Verify strict mode surfaces the original missing path, resource kind, and phase. - -### Progress (2026-03-13) -- Done: resolver-owned missing-resource policy. - - Added `ResourceKind` and `MissingResourceException`. - - Added `fs_debug_loaders` handling in `CakeFileResolver`. - - Moved raw lookup into `tryResolve(...)`. - - Added resolver-local warn-once behavior. - - Added initial fallback assets: - - `_missing.pcx` - - `_missing.wal` - - `_missing.md2` - - `_missing.sp2` -- Verified: - - strict mode throws for missing `md2` - - fallback mode redirects missing `pcx` - - missing `sound/*` remains tolerable -- Done: split raw existence probes from policy-aware loads. - - Added `tryResolveRaw(...)` helper for code paths that are selecting candidates rather than loading. - - Updated `GameConfiguration`, `PlayerConfiguration`, cinematics, and effect precache to stop probing through policy-aware `resolve(...)`. - - Actual asset loads now go through the resolver policy, while legacy fallback-selection logic still uses raw existence checks. -- Done: Cake missing-resource recovery boundary. - - Wrapped the main Cake frame/update path so `MissingResourceException` drops cleanly to console instead of terminating the client. - - Removed the last direct `Com.Error(...)` call from `cake/`. - - Handled missing-resource recovery by disposing the active `Game3dScreen` and unloading its config-owned assets through the existing disconnect path. -- Done: loader-side null-file hardening for dependency discovery. - - `Md2Loader` and `Sp2Loader` now throw `MissingResourceException` instead of silently accepting `null` dependency files. diff --git a/info/planning/cake-multiplayer-menu-todo.md b/info/planning/cake-multiplayer-menu-todo.md deleted file mode 100644 index 619d9c042..000000000 --- a/info/planning/cake-multiplayer-menu-todo.md +++ /dev/null @@ -1,16 +0,0 @@ -# Cake Multiplayer Menu TODO - -The implemented multiplayer and player-setup screen architecture is now documented in: - -- `cake/core/src/main/kotlin/org/demoth/cake/stages/README.md` - -This file is intentionally reduced to active follow-up items only. - -## Open Follow-Ups - -- Decide whether `rate` belongs in `Player Setup` or a later options/settings surface. - -- Only add player preview support if it stays isolated from: - - menu-state routing - - player setup validation - - asset catalog discovery diff --git a/info/planning/console-todo.md b/info/planning/console-todo.md deleted file mode 100644 index 3d6dc148a..000000000 --- a/info/planning/console-todo.md +++ /dev/null @@ -1,25 +0,0 @@ -# Console Follow-Ups - -The settled console architecture and current implementation now live in: - -- `cake/core/src/main/kotlin/org/demoth/cake/stages/console/README.md` - -This file is intentionally reduced to active follow-up items only. - -## Open Follow-Ups - -- Add output text selection/copy only if it stays simple with the current wrapped-row and line-scroll model. - -- Decide whether styling-only console commands should remain local to `ConsoleStage`: - - `clear` - - `console_print` - -- Decide whether the current `Stack` + `console-panel` framing should become a reusable console-specific widget. - -## Deferred - -- advanced command-specific completion -- map/file/demo completion -- perfect pixel-precise text selection -- reworking the whole command system -- replacing Scene2D diff --git a/info/planning/downloads-todo.md b/info/planning/downloads-todo.md deleted file mode 100644 index d35b350eb..000000000 --- a/info/planning/downloads-todo.md +++ /dev/null @@ -1,257 +0,0 @@ -# Cake Downloads TODO - -## Goal -- Make Cake usable on modded multiplayer servers that require custom maps and assets. -- Restore Quake II-compatible autodownload behavior without introducing Cake-specific protocol changes. -- Keep the first implementation compatible with vanilla Jake2 server support and future-friendly for R1Q2/Q2Pro-style HTTP extensions. - -## Agreed Scope - -### Phase 1 -- Global download cache. -- Profile-local cvar switches. -- Vanilla UDP autodownload compatibility first. -- Strict filename validation. -- Download queue with temp file, resume, and rename. -- Refresh mounted content after successful download. - -### Deferred -- HTTP download support. -- Cake-specific protocol extensions. -- Additional content categories beyond the vanilla set. -- Rich download UI beyond basic loading/progress feedback. - -## Current Jake2 / Cake State -- Cake currently disables UDP download behavior in `cake/core/src/main/kotlin/org/demoth/cake/Cake.kt`: - - `// no udp downloads anymore!!` -- Server-side UDP download support is still present: - - `server/src/main/java/jake2/server/SV_MAIN.java` - - `server/src/main/java/jake2/server/SV_USER.java` -- Shared network protocol pieces still exist: - - `qcommon/src/main/java/jake2/qcommon/network/messages/client/StringCmdMessage.java` - - `qcommon/src/main/java/jake2/qcommon/network/messages/server/DownloadMessage.java` -- Legacy Jake2 client still contains a full reference implementation: - - `client/src/main/java/jake2/client/CL.java` - - `client/src/main/java/jake2/client/CL_parse.java` - - `client/src/main/java/jake2/client/Menu.java` - -## Storage Decision - -### Recommended: Global Download Cache -- Downloaded assets are shared content, not profile identity or preferences. -- Recommended shape: - - `~/.cake/downloads/baseq2/...` - - `~/.cake/downloads/xatrix/...` - - `~/.cake/downloads//...` - -### Pros -- No duplicate downloads across profiles. -- Better fit for cache semantics. -- Easier testing across multiple profiles. -- Better disk usage. - -### Cons -- Requires strict validation and namespacing. -- Less isolated than profile-local storage. -- Cleanup is not automatically tied to a single profile. - -### Rejected For Phase 1: Per-Profile Download Storage -- Pros: - - strong isolation - - simpler cleanup per profile -- Cons: - - duplicate downloads - - unnecessary disk use - - worse UX for multi-profile testing - -## Config / Cvar Policy -- Download behavior is controlled by profile-local archived cvars. -- Downloaded content itself lives in the global cache. - -### Phase 1 Cvars -- `allow_download` -- `allow_download_maps` -- `allow_download_models` -- `allow_download_players` -- `allow_download_sounds` - -### Deferred Cvars -- `cl_http_downloads` -- `cl_nodownload_list` -- non-vanilla categories like: - - `allow_download_pics` - - `allow_download_textures` - - `allow_download_textures_24bit` - -## Reference Review - -### Vanilla / Legacy Jake2 -- Uses one-file-at-a-time UDP downloads. -- Supports temp file + resume + rename. -- Uses the classic `allow_download*` cvar set. -- Client references: - - `client/src/main/java/jake2/client/CL.java` - - `client/src/main/java/jake2/client/CL_parse.java` -- UI reference: - - `client/src/main/java/jake2/client/Menu.java` - -### Yamagi -- Keeps the classic `allow_download*` cvars. -- Adds: - - `cl_http_downloads` - - `cl_nodownload_list` -- Applies stricter path filtering before download. -- Uses HTTP when server support exists, with UDP fallback. -- References: - - `../quake/quake2/yquake2/src/client/cl_download.c` - - `../quake/quake2/yquake2/src/client/menu/menu.c` - -### Q2Pro -- Uses a queue-based download manager. -- Supports HTTP plus UDP fallback. -- Adds extra categories beyond vanilla: - - `allow_download_pics` - - `allow_download_textures` -- Supports `allow_download == -1` as stronger disable semantics. -- References: - - `../quake/quake2/q2pro/src/client/download.c` - - `../quake/quake2/q2pro/src/client/http.c` - - `../quake/quake2/q2pro/src/client/ui/q2pro.menu` - -### KMQuake2 -- Similar to Yamagi/Q2Pro. -- Adds more categories including `allow_download_textures_24bit`. -- Supports HTTP server advertisement. -- References: - - `../quake/quake2/KMQuake2/client/cl_download.c` - - `../quake/quake2/KMQuake2/client/cl_http.c` - - `../quake/quake2/KMQuake2/ui/ui_mp_download.c` - - `../quake/quake2/KMQuake2/server/sv_user.c` - -### Quetoo -- Treats HTTP as the preferred path and UDP as fallback. -- Adds downloaded `.pk3` files to the search path immediately after completion. -- References: - - `../quake/quake2/quetoo/src/client/cl_media.c` - - `../quake/quake2/quetoo/src/client/cl_parse.c` - - `../quake/quake2/quetoo/src/server/sv_client.c` - -## Compatibility Constraint -- Java having an embedded HTTP client is not the hard part. -- The missing part in this repo is HTTP discovery/integration: - - Jake2 server currently has UDP download support. - - Jake2 server does not currently advertise an HTTP download URL like R1Q2/Q2Pro/Yamagi/KMQuake2. -- Because of that: - - HTTP-first is not actually the smallest compatible implementation for public servers. - - UDP-first is the smallest path that works with the current repo and protocol. - -## Phase 1 Design - -### 1. Download Manager -- Add a Cake-side download manager responsible for: - - queuing missing files during precache - - validating paths - - opening/resuming temp files - - writing incoming chunks - - renaming completed files - - reporting progress - - triggering mount/content refresh - -### 2. Transport -- Reuse the existing Quake II UDP download protocol: - - client command: `download [offset]` - - server message: `svc_download` - - continuation command after partial chunk: `nextdl` -- Design the manager so transport can be abstracted later: - - `UdpDownloadTransport` first - - `HttpDownloadTransport` later - -### 3. Download Queue -- Queue missing precache assets instead of ad-hoc single-call flow. -- Phase 1 still downloads one file at a time on the wire. -- The queue exists so missing resources can be discovered first and then processed consistently. - -### 4. Temp / Resume / Rename -- Match the proven legacy pattern: - - download to `.tmp` - - if temp exists, resume from its current size - - rename to final path on successful completion - -### 5. Strict Filename Validation -- Reject: - - `..` - - absolute paths - - drive letters / `:` - - backslashes - - leading `.` or `/` - - dangerous native-library extensions like `.dll`, `.so`, `.dylib` -- Only allow expected content locations such as: - - `maps/` - - `models/` - - `players/` - - `sound/` - -### 6. Category Gating -- Before enqueuing a file, check: - - `allow_download` - - matching category cvar derived from path prefix - -### 7. Mounted Content Refresh -- After successful download, newly written files must become visible to the asset/VFS layer. -- This is especially important for: - - maps - - models - - player assets - - sounds -- The exact hook point still needs implementation design, but refresh is part of Phase 1, not an optional extra. - -## Future HTTP Plan -- Do not invent Cake-specific protocol extensions unless R1Q2/Q2Pro-style extensions are explicitly rejected. -- Preferred future order: - 1. implement R1Q2/Q2Pro-style download URL discovery if compatible - 2. add `cl_http_downloads` - 3. prefer HTTP when advertised - 4. fall back to UDP - -## Open Questions -- What is the cleanest refresh point for newly downloaded content in Cake: - - remount/reindex VFS - - targeted resolver refresh - - explicit reload of stage-local caches -- Should `.pak` / `.pk3` downloads be allowed in phase 1, or should phase 1 be limited to loose files only? -- Where should minimal progress be surfaced first: - - loading/precache screen - - console - - both - -## Immediate Next Steps -1. Register the phase-1 download cvars in Cake startup. -2. Decide and implement the global download root helper. -3. Extract a Cake-side download manager skeleton with queue and validation. -4. Reuse the existing UDP protocol messages in Cake networking. -5. Integrate downloads into the current precache flow. -6. Add VFS/content refresh after completion. - -## Progress -- Done: - - documented the phase-1 design and reference review - - registered the classic `allow_download*` client cvars in Cake startup - - added a dedicated global download cache helper with per-mod roots - - added a Cake-side download manager skeleton with: - - queue ownership - - category classification - - strict path validation - - temp-path derivation - - added UDP transfer state with: - - resume offset detection - - chunk append - - temp-to-final rename - - wired Cake precache to: - - pause on missing required assets - - start/resume UDP downloads - - retry `precache` after completion - - made downloaded loose files immediately visible to `CakeFileResolver` -- Next: - - extend coverage beyond required missing assets discovered during precache - - improve UX/progress messaging during active downloads - - decide whether optional/tolerable missing sounds should also participate in autodownload diff --git a/info/planning/options-todo.md b/info/planning/options-todo.md deleted file mode 100644 index 2125e4332..000000000 --- a/info/planning/options-todo.md +++ /dev/null @@ -1,412 +0,0 @@ -# Cake Options TODO - -## Goal -- Replace the current dead `Options` button in Cake with a usable options flow. -- Keep the first implementation thin-client scoped and incremental. -- Avoid painting the project into a corner around launcher-owned video settings and runtime cvar behavior. - -## Current Cake State -- `Options` in `cake/core/src/main/kotlin/org/demoth/cake/stages/MainMenuStage.kt` is still a stub. -- Cake already has profile-local config persistence for archived cvars and key bindings. -- The desktop launcher owns initial window creation in `cake/cake-client/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3GameLauncher.kt`. -- Current launcher video behavior is hardcoded at startup: - - `useVsync(true)` - - `setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate)` - - `setResizable(false)` - - `setFullscreenMode(Lwjgl3ApplicationConfiguration.getDisplayMode())` -- Shared `Cvar` is currently a storage/update primitive, not a behavior binding system: - - `name` - - `string` - - `value` - - `flags` - - `modified` - - `latched_string` -- There is no built-in per-cvar update callback or listener mechanism today. - -## Reference Findings - -### 1. Vanilla / Jake2 Legacy -- Legacy Jake2 options in `client/src/main/java/jake2/client/Menu.java` follow the original Quake II shape: - - one mixed `Options` screen - - audio + controls + crosshair on the same page - - actions for `customize controls`, `reset defaults`, `go to console` -- Yamagi keeps the same overall shape in `../quake/quake2/yquake2/src/client/menu/menu.c`, but modernizes some entries: - - OGG music - - sound backend selection - - pause-on-minimize behavior - -### 2. Category Hub -- KMQuake2 splits `Options` into a root hub plus focused submenus: - - `ui_options.c` - - `ui_options_sound.c` - - `ui_options_controls.c` - - `ui_options_screen.c` - - `ui_options_effects.c` - - `ui_options_interface.c` -- Q2Pro does the same conceptually, but with data-driven menu scripts in `../quake/quake2/q2pro/src/client/ui/q2pro.menu`. -- In both KMQuake2 and Q2Pro, the root `Options` page is mostly navigation, not a giant form. - -### 3. Modern Outlier -- Quetoo uses a more application-like UI architecture: - - controller code in `../quake/quake2/quetoo/src/cgame/default/ui/settings/OptionsViewController.c` - - declarative layout in `../quake/quake2/quetoo/src/cgame/default/ui/settings/OptionsViewController.json` -- This is useful as a design reference, but it is not a near-term fit for Cake’s current menu/stage architecture. - -## Common Ground Across References -- Sound volume is always present. -- Music volume or music enable is always present. -- Mouse sensitivity is always present. -- Invert mouse is always present. -- Always run / freelook style movement controls are common. -- Crosshair or HUD/screen controls are common. -- Key rebinding entry point is common. -- Video settings are always present, either directly or via submenu. -- Reset defaults is common. - -## Main Differences Across References -- The main difference is structure: - - old clients use one long mixed options page - - newer/more extended clients use an options hub with categorized submenus -- More advanced clients add specialized categories: - - effects - - screen/HUD - - downloads - - address book - - interface -- More advanced clients also introduce settings that are not just direct 1:1 cvar toggles: - - quality presets - - grouped effect policies - - restart/reload-required video changes - -## Recommendation For Cake -- Do not copy the old single-screen legacy menu. -- Do not jump straight to a Quetoo-style UI framework. -- Follow the KMQuake2 / Q2Pro direction: - - an `Options` hub screen - - a few focused submenus - -## Agreed Plan -- Options are cvar-backed. - - Cvars remain the source of truth for console, config persistence, and options UI. -- Options use a Q2Pro-style hub with submenus. -- `userinfo` cvars are not part of `Options`. - - They belong to `Player Setup`, like in vanilla Quake II. -- Initial submenu grouping is prefix-based: - - `s_` -> sound - - `vid_` -> video/display - - `in_` -> input - - `r_` -> rendering/visuals - - `cl_` -> generic client settings -- Prefix alone is not enough to expose a cvar. - - A cvar must also opt in via an options-related cvar flag. -- For now, grouping should stay simple: - - prefix selects the section - - alphabetical order inside a section - - no second explicit grouping layer yet -- Cvars with missing or imperfect prefixes can be handled later: - - canonical rename + legacy alias - - migration - - manual cleanup -- Options UI uses canonical prefixed names only. - - Legacy aliases exist for console/exec/config compatibility, not for UI display. -- Aliases are primarily for compatibility with old script/config/console usage. - - New code and config writing should prefer canonical names. -- Optional description should be supported, but not required for the first slice. -- Typed widgets are explicitly deferred to the next phase. - - First slice only needs cvar discovery and basic metadata groundwork. -- `CVAR_LATCH` should be generalized for Cake-facing restart-required client settings. - - The same flag can represent “applies on next client restart” for Cake-owned option cvars. - - Implementation still needs to avoid breaking current server/game latch behavior. - -## Proposed Cake v1 Shape -- `Options` - - `Video` - - `Sound` - - `Controls` -- Optional later categories: - - `Screen` - - `Effects` - -## Usable Cake Milestone Scope -- The release should keep `Options` intentionally small and behavior-backed. -- Every option shown for this milestone should satisfy all of these: - - persists between runs - - has real runtime behavior in Cake today - - belongs to local client settings, not `userinfo` - - does not depend on an unfinished launcher/restart workflow - -### Essential Release Set -- `s_volume` -- `in_sensitivity` -- `in_invert_mouse` -- `cl_run` -- `cl_crosshair` -- `cl_showfps` - -### Good Additions For This Release -- `vid_gamma` -- `r_dlights` -- `r_particles` -- `r_post_vignette` -- `r_post_vignette_strength` -- `r_underwater_warp` - -### Do Not Block Release On These -- `lookspring` -- `lookstrafe` -- `freelook` -- crosshair scale / alpha / pulse -- HUD scale / HUD alpha -- fullscreen / resolution / vsync - -### Explicitly Out Of Release Scope -- OGG/music settings -- sound backend / sound quality selectors -- download options -- joystick options -- third-person options -- interface font / console theme customization - -## First Fields Worth Shipping - -### Video -- fullscreen mode -- resolution -- vsync -- maybe menu/UI scale later - -### Sound -- master/effects volume -- music volume - -### Controls -- mouse sensitivity -- invert mouse -- always run -- freelook - -## The Real Apply Problem -- A form bound to cvars is not enough. -- The important question is when and how changing a cvar actually updates behavior. - -## Required Setting Classes - -### 1. Immediate Apply -- Safe to apply during runtime with no restart. -- Examples: - - sound/music volume - - mouse sensitivity - - invert mouse - - always run - - freelook - - UI-only scale/opacity settings once Cake owns them directly - -### 2. Recreate / Reconnect / Reload -- Changes may require rebuilding local runtime state, but not a full process restart. -- Candidates: - - some renderer toggles - - screen/HUD setup if cached into render helpers - - effect toggles if subsystems cache derived values instead of polling cvars - -### 3. Full App Restart -- These are launcher-owned today, not Cake-runtime-owned: - - fullscreen - - resolution - - vsync - - MSAA / backbuffer config - - possibly resizable / HiDPI policy -- These cannot be honestly implemented as normal in-game cvar changes without changing launcher ownership. - -## Validating the Restart Ideas - -### Require Restart -- This is the safest first implementation for launcher-owned video settings. -- It is easy to explain in the UI. -- It avoids introducing fragile process/control flow before the options screen exists. -- Recommendation: - - mark video settings as `restart required` - - save them immediately to profile config - - prompt the user to restart Cake - -### Exit Cake And Let Launcher Restart -- This is a valid future direction, but it is not a cheap first step. -- To do it cleanly, the launcher must become a real owner of application lifecycle, not just `new Lwjgl3Application(...)`. -- It would need: - - a restart contract from Cake to launcher - - persisted window/video config source of truth - - possibly reconnect context if reconnect-after-restart is desired - - careful UX around unsaved state and active connections -- Recommendation: - - do not make this a prerequisite for the first options implementation - -## Validating the Cvar Callback Idea -- The direction is good. -- The scope should stay incremental. - -### What Is Good About It -- It gives cvars real behavior, not just storage. -- It removes ad hoc polling or scattered “read cvar every frame” patterns. -- It makes option forms easier to connect to runtime systems. - -### What Needs Care -- Not every cvar should own a direct side-effect callback. -- Some cvars are: - - network-facing userinfo - - launcher-owned startup config - - server/game latched values -- These should not all be flattened into one “set value -> callback” model. - -### Example Caveat -- `fov` in the current Quake II model is not a purely local Cake camera setting. -- It is a legacy userinfo cvar and feeds server/player-state logic. -- So `Cvar.Register("cl_fov", update = { old, new -> camera.fov = new })` is a good shape for a future client-only cvar, but it is not a drop-in model for the existing `fov` cvar. - -## Recommended Incremental Cvar Evolution -- Keep the current cvar storage and flags model intact. -- Add optional metadata/behavior in layers instead of replacing everything. - -### Suggested Direction -- Introduce a higher-level registration API for Cake-owned cvars only. -- Give each registered cvar an apply policy: - - `IMMEDIATE` - - `RECREATE_RUNTIME` - - `RESTART_APP` -- Allow an optional update hook for `IMMEDIATE` and maybe `RECREATE_RUNTIME`. -- Keep plain legacy cvars working exactly as they do today. - -### Example Shape -- `registerCakeCvar(name, defaultValue, flags, applyPolicy, onChange?)` -- This should sit on top of `Cvar`, not replace `Cvar` all at once. - -## Recommendation For Options Work -- Use the options screen as the moment to revisit cvars, but keep the rollout incremental. -- First: - - build cvar discovery support for opt-in option cvars - - build the `Options` hub - - expose `Sound` and `Controls` -- Then: - - expose minimal `Video` - - use generalized latch semantics for restart-required settings -- After that: - - add typed cvar metadata for widget inference and stronger validation - -## Validate: Use Cvar As The Backing Structure -- This idea is sound. -- There is no strong reason to invent a separate persistent `Option` model just to drive the UI. -- `Cvar` is already the shared source of truth for: - - current value - - persistence through `CVAR_ARCHIVE` - - command/console interoperability - - userinfo/serverinfo propagation -- Recommendation: - - keep `Cvar` / `cvar_t` as the backing data structure - - extend it with optional metadata needed by Cake options UI - - avoid a second parallel registry that would drift from real cvar state - -## Validate: Reuse Latched Semantics For Client Restart -- The idea remains good. -- Restart-required values are conceptually “latched until a later apply boundary”. -- That matches `latched_string`. -- Agreed direction: - - reuse `CVAR_LATCH` for Cake-facing restart-required settings - - treat it as “next client restart” for those cvars -- Implementation note: - - current `CVAR_LATCH` behavior is still server-oriented in `Cvar.java` - - Cake/client usage should be generalized carefully so server/game behavior keeps working - -## Validate: Make Cvars More Type Safe -- This idea is strong. -- It is probably the most useful long-term improvement for options work. -- It is intentionally not part of the first options PoC. - -### Why It Helps -- It makes invalid values harder to set. -- It lets the UI infer controls automatically. -- It gives the console and config system richer validation. -- It reduces duplicated menu code that currently hardcodes slider ranges and yes/no lists. - -### Useful Metadata To Add Later -- description/help text -- value kind: - - boolean - - integer - - float - - enum - - string -- numeric constraints: - - min - - max - - step -- allowed values for enum-like cvars -- optional on-change/update callback - -### What The UI Can Infer Later -- boolean -> checkbox / toggle -- integer or float range -> slider or spinner -- enum -> select box -- constrained string -> select box or validated text field -- unconstrained string -> text field - -### Important Constraint -- Do not try to retrofit every legacy cvar in one pass. -- Some cvars are: - - free-form - - engine internal - - command-adjacent - - not suitable for normal options UI -- So metadata should be optional, not mandatory for all cvars. - -## Recommended Incremental Shape -- Keep legacy `FindVar/Get/Set` behavior working. -- Extend `cvar_t` or the registration path with optional metadata. -- Let Cake options render only cvars that opt in through flags. -- Let prefixes provide the first-pass section mapping. - -### Good End State -- One registry -- One source of truth -- Console and UI both operate on the same cvars -- UI widgets can be generated mostly from cvar metadata -- Restart-required video settings can be shown honestly without fake live-apply behavior - -## Example Direction -- First phase: - - `register/get cvar + flags + optional description` -- Later phase: - - `registerCvar(name, defaultValue, flags, description, type, allowedValues, onChange?)` -- The exact API can differ, but the direction is correct: - - cvars remain the backing model - - typed metadata drives validation and UI generation - - latch semantics explain restart-required behavior - -## Immediate Next Steps -1. Continue migrating safe local option cvars to canonical prefixed names plus legacy aliases. -2. Keep registering option cvars during Cake startup before profile config load. -3. Add the first genuinely useful `Sound` section entries. -4. Add more `Controls` entries that are runtime-safe. -5. Decide the persisted schema/source of truth for launcher-owned video settings. -6. Add `Video` submenu entries with `restart required` labeling first. - -## Progress -- Done: - - added `CVAR_OPTIONS` and optional cvar description metadata - - added sorted cvar discovery by `prefix + flag` - - tagged an initial set of real Cake cvars for options discovery - - replaced the dead main-menu `Options` button with a real options hub - - added a generic options section screen driven by discovered cvars - - added cvar alias support for legacy console/exec/config compatibility - - switched the first safe local cvars to canonical prefixed names: - - `sensitivity` -> `in_sensitivity` - - `crosshair` -> `cl_crosshair` - - kept legacy names as aliases so old configs/scripts still resolve - - moved option-cvar registration into Cake startup so the options menu sees canonical entries immediately - - added a live `Sound` option backed by `s_volume` - - added a live `Controls` option backed by `in_invert_mouse` - - added a live `Controls` option backed by `cl_run` - - added Quake-style `+speed` handling so `cl_run` is a real default movement mode, not just a dead toggle -- Current limitations: - - section membership is still pure prefix-based - - option editing is generic text-field based for now - - only a small subset of cvars is currently migrated/opted in - - launcher-owned video settings still do not have a proper restart/apply flow - - typed widgets and stronger validation remain the next phase diff --git a/info/planning/rendering-todo.md b/info/planning/rendering-todo.md deleted file mode 100644 index 682b8f368..000000000 --- a/info/planning/rendering-todo.md +++ /dev/null @@ -1,35 +0,0 @@ -# Rendering TODO (Cake) - -Stable rendering/runtime architecture now lives in: - -- `cake/core/src/main/kotlin/org/demoth/cake/assets/README.md` -- `cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/README.md` -- `cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md` - -This file is intentionally reduced to active rendering follow-ups only. - -## Active Follow-Ups - -- Optional non-legacy enhancement: smooth lightstyle interpolation between 100 ms ticks. - - Keep legacy-discrete behavior as the baseline. - - If implemented, make it a deliberate opt-in rather than the default. - -- Widen static-image cinematic/end-screen support beyond `.pcx`. - - Target formats: `.tga`, `.png`, `.jpg`. - - Reuse the existing texture loader pipeline where practical instead of building a parallel image path. - -- Keep validating renderer parity under stress scenes: - - dense dynamic-light overlap - - high particle pressure - - cinematic/control-plane transitions - -## Deferred - -- Additional non-legacy renderer polish beyond parity-driven work. -- Any larger renderer rewrite that is not required for gameplay/readability parity. - -## References - -- Legacy Jake2 renderer/runtime: `client/` -- Yamagi GL3 reference: `../quake/quake2/yquake2` -- Q2PRO batching reference: `../quake/quake2/q2pro` diff --git a/info/planning/sound-todo.md b/info/planning/sound-todo.md deleted file mode 100644 index e44b5c3bf..000000000 --- a/info/planning/sound-todo.md +++ /dev/null @@ -1,70 +0,0 @@ -# Sound TODO (Cake vs Yamagi/Quetoo) - -Stable architecture and current runtime behavior now live in: - -- `sound/README.md` -- `cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/README.md` - -This file is intentionally reduced to active parity and diagnostics follow-ups only. - -## Pending (current priority) - -### 1) Channel semantics parity hardening - -Status: - -- Baseline override behavior is in place. -- Remaining parity details (under channel pressure/high concurrency) are not fully verified. - -TODO: - -- Verify and align replacement/stealing behavior vs legacy mixers under stress. -- Add targeted diagnostics to inspect active channels and replacements. - -### 2) One-shot tracking parity gaps - -Status: - -- Explicit-channel sounds are tracked/respatialized. -- `CHAN_AUTO` one-shots are still fire-and-forget after start. - -TODO: - -- Decide whether to keep `CHAN_AUTO` fire-and-forget or add bounded short-lived tracking for closer parity. -- Add regression checks for moving-source one-shot behavior. - -### 3) Entity event coverage completion - -TODO: - -- Validate `EV_OTHER_TELEPORT` handling expectations and decide if it should map to an explicit entity-event path. - -### 4) Cross-cutting quality tasks - -TODO: - -- Add Cake audio cvars parity set (at least `s_volume`, `cl_footsteps`; split volumes optional). -- Add audio debug commands/diagnostics: - - active channels - - loaded sounds - - backend state -- Add integration tests around `SoundMessage` handling: - - `timeOffset` - - explicit channel override - - sexed (`*`) sound resolution - -## Deferred (future, not current priority) - -- Music/streaming subsystem parity. -- Environmental filters/effects: - - occlusion - - underwater filtering - - reverb -- Doppler behavior parity. - -## References - -- Server send path: `server/src/main/java/jake2/server/SV_SEND.java` -- Packet model: `qcommon/src/main/java/jake2/qcommon/network/messages/server/SoundMessage.java` -- Cake ingest path: `cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt` -- Legacy reference (events/particles): `client/src/main/java/jake2/client/CL_fx.java` diff --git a/sound/README.md b/info/sound.md similarity index 67% rename from sound/README.md rename to info/sound.md index 5f0cd7d50..e02e49106 100644 --- a/sound/README.md +++ b/info/sound.md @@ -42,9 +42,14 @@ Per render frame: ### 4) Channel semantics (current baseline) - Explicit channels (`CHAN_* != CHAN_AUTO`) are keyed by `(entity, channel)` and override previous active voice on the same key. -- `CHAN_AUTO` currently behaves as fire-and-forget (no override key). +- `CHAN_AUTO` allows overlapping one-shots without a keyed override. All one-shots are tracked until their estimated end time and respatialized each frame. - `SoundMessage.timeOffset` is honored for delayed one-shot scheduling. +One-shots and entity loops share the `s_maxvoices` budget. Under pressure, a +one-shot replaces the eligible voice with the shortest remaining lifetime; +non-player sounds cannot steal the local player's voices. Loop synchronization +keeps existing loops stable and skips new loops when the pool is full. + ### 5) Non-spatial exceptions The backend treats these as non-spatial/full-center pan: @@ -75,9 +80,9 @@ Item respawn and player teleport also trigger their paired visual effect paths f ## Current producers -- Server sound packets: `Game3dScreen.processSoundMessage` -- Weapon sound packets: `Game3dScreen.processWeaponSoundMessage` -- Entity events: `Game3dScreen.playEntityEventSounds` +- Server sound packets: `IngameSoundMessageHandler.processSoundMessage` +- Weapon sound packets: `IngameSoundMessageHandler.processWeaponSoundMessage` +- Entity events: `IngameSoundMessageHandler.playEntityEventSounds` - Temp/effect sounds: `ClientEffectsSystem.playEffectSound` ## Transition lifecycle @@ -92,7 +97,11 @@ This prevents lingering sounds across reconnect/map changes. ## Known limits -- No strict full parity yet for all channel-pressure/voice-stealing edge cases. -- `CHAN_AUTO` one-shots are not persistently tracked after playback starts. -- `EV_OTHER_TELEPORT` still needs an explicit parity decision. -- Music/streaming, environmental filters, and doppler are deferred for future work. +- Voice allocation has regression coverage for channel overrides, voice stealing, + local-player protection, and stable loops under pressure. This does not establish + full parity with every legacy mixer edge case. +- One-shot lifetimes use decoded sound durations, with an estimate when the duration + is unknown. +- Footsteps currently have no `cl_footsteps` gate. +- Music playback, environmental filters, and Doppler are not implemented. Cinematic + audio streaming is handled separately by `CinematicPresentationController`. diff --git a/qcommon/vfs/README.md b/info/vfs.md similarity index 98% rename from qcommon/vfs/README.md rename to info/vfs.md index 733b36284..38857cb49 100644 --- a/qcommon/vfs/README.md +++ b/info/vfs.md @@ -16,7 +16,7 @@ shared VFS implementation, with the same lookup semantics. - **Client:** `CakeVfsAssetSource` owns a `DefaultVirtualFileSystem`, adapted to libGDX file handles. `CakeFileResolver` adds download-cache precedence and bundled fallbacks; see [Cake asset loading](../../cake/core/src/main/kotlin/org/demoth/cake/assets/README.md). - The model viewer uses the same resolver with startup hints derived from the opened + The viewer uses the same resolver with startup hints derived from the opened file and current directory. - `DefaultWritableFileSystem` implements `WritableFileSystem` for a caller-selected root. `EngineWriteRoot` supplies the server's writable instance. diff --git a/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java b/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java index 33fea7f0d..b98a1cdd9 100644 --- a/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java +++ b/qcommon/src/main/java/jake2/qcommon/exec/Cvar.java @@ -52,6 +52,7 @@ of the License, or (at your option) any later version. /** * Cvar implements console variables. The original code is located in cvar.c */ +// TODO: cleanup: rename Cvar to CvarRegistry so the entry can take the name Cvar. public class Cvar { private static final Cvar instance = new Cvar(); diff --git a/qcommon/src/main/java/jake2/qcommon/exec/README.md b/qcommon/src/main/java/jake2/qcommon/exec/README.md index c146f5c23..4bc1fb975 100644 --- a/qcommon/src/main/java/jake2/qcommon/exec/README.md +++ b/qcommon/src/main/java/jake2/qcommon/exec/README.md @@ -3,6 +3,7 @@ This package is responsible for internal command execution (like when you type i Current metadata support: - commands may carry an optional description through `Cmd.AddCommand(...)` -- cvars may carry descriptions and options metadata through `Cvar.Get(...)` +- cvars declare their type, default, flags and optional description through + `Cvar.register(name, type, defaultValue, flags, description)` on a registry instance Cake uses that metadata to render richer console completion output. diff --git a/qcommon/src/main/java/jake2/qcommon/exec/cvar_t.java b/qcommon/src/main/java/jake2/qcommon/exec/cvar_t.java index 42760f02f..bd8df027d 100644 --- a/qcommon/src/main/java/jake2/qcommon/exec/cvar_t.java +++ b/qcommon/src/main/java/jake2/qcommon/exec/cvar_t.java @@ -35,10 +35,12 @@ of the License, or (at your option) any later version. import static jake2.qcommon.Defines.*; /** One typed variable with immutable declaration metadata and validated active/pending values. */ +// TODO: cleanup: rename cvar_t to Cvar when the registry is renamed to CvarRegistry. public final class cvar_t { public final String name; public final @Nullable String description; public final int flags; + // TODO: cleanup: remove unused modification tracking after checking remaining test dependencies. public boolean modified; private final CvarType type; @@ -55,6 +57,7 @@ public final class cvar_t { public @NotNull CvarType getType() { return type; } public @NotNull T getDefaultValue() { return defaultValue; } + // TODO: cleanup: rename getTypedValue to getValue (Kotlin typedValue to value). public @NotNull T getTypedValue() { return active.getValue(); } /** Null means no queued change; false, zero and an empty String remain valid pending values. */ diff --git a/qcommon/src/main/java/jake2/qcommon/vfs/DefaultVirtualFileSystem.java b/qcommon/src/main/java/jake2/qcommon/vfs/DefaultVirtualFileSystem.java index a981aae6e..9128e759f 100644 --- a/qcommon/src/main/java/jake2/qcommon/vfs/DefaultVirtualFileSystem.java +++ b/qcommon/src/main/java/jake2/qcommon/vfs/DefaultVirtualFileSystem.java @@ -196,6 +196,14 @@ public synchronized void rebuildIndex(RebuildScope scope) { rebuildAll(); } + @Override + public synchronized List listEntries() { + ensureConfigured(); + return flattenedIndex.values().stream() + .sorted(Comparator.comparing(VfsEntry::normalizedPath)) + .toList(); + } + /** * Returns resolved winner paths in ascending order. */ diff --git a/qcommon/src/main/java/jake2/qcommon/vfs/VirtualFileSystem.java b/qcommon/src/main/java/jake2/qcommon/vfs/VirtualFileSystem.java index 463f05901..53dfb8b5a 100644 --- a/qcommon/src/main/java/jake2/qcommon/vfs/VirtualFileSystem.java +++ b/qcommon/src/main/java/jake2/qcommon/vfs/VirtualFileSystem.java @@ -1,5 +1,7 @@ package jake2.qcommon.vfs; +import java.util.List; + /** * Core read-focused virtual file system contract shared across modules. */ @@ -12,6 +14,13 @@ default VfsLookupResult resolve(String logicalPath) { VfsLookupResult resolve(String logicalPath, boolean gameDataOnly); + /** + * Immutable snapshot of resolved files, sorted by normalized logical path. + * Contains one winning entry per path, including its size and source metadata. + * Later mount or configuration changes do not modify a returned snapshot. + */ + List listEntries(); + VfsResult loadBytes(String logicalPath); VfsResult openRead(String logicalPath, VfsOpenOptions options); diff --git a/qcommon/src/test/java/jake2/qcommon/vfs/DefaultVirtualFileSystemTest.java b/qcommon/src/test/java/jake2/qcommon/vfs/DefaultVirtualFileSystemTest.java index 24266465e..4c35a6f22 100644 --- a/qcommon/src/test/java/jake2/qcommon/vfs/DefaultVirtualFileSystemTest.java +++ b/qcommon/src/test/java/jake2/qcommon/vfs/DefaultVirtualFileSystemTest.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; @@ -20,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class DefaultVirtualFileSystemTest { @@ -173,6 +175,58 @@ public void baseZipPackIsIndexed() throws Exception { assertEquals(VfsLayer.BASE_PACK, vfs.resolve("textures/zip.wal").entry().layer()); } + @Test + public void listsResolvedWinnersAsImmutableSnapshot() throws Exception { + Path basedir = temp.resolve("q2"); + writeFile(basedir.resolve("baseq2/shared.cfg"), "base"); + writeFile(basedir.resolve("baseq2/z.cfg"), "z"); + writeFile(basedir.resolve("rogue/Shared.cfg"), "modded"); + writeFile(basedir.resolve("rogue/A.cfg"), "a"); + VirtualFileSystem vfs = new DefaultVirtualFileSystem(); + vfs.configure(config(basedir, "rogue", false, false, List.of())); + + List snapshot = vfs.listEntries(); + + assertEquals(List.of("a.cfg", "shared.cfg", "z.cfg"), snapshot.stream().map(VfsEntry::normalizedPath).toList()); + VfsEntry shared = snapshot.get(1); + assertEquals(vfs.resolve("shared.cfg").entry(), shared); + assertEquals("Shared.cfg", shared.displayPath()); + assertEquals(6, shared.size()); + assertEquals(VfsSourceType.DIRECTORY, shared.source().type()); + assertEquals(basedir.resolve("rogue"), shared.source().containerPath()); + assertThrows(UnsupportedOperationException.class, snapshot::clear); + + vfs.setGameMod(null); + + assertEquals(List.of("shared.cfg", "z.cfg"), vfs.listEntries().stream().map(VfsEntry::normalizedPath).toList()); + assertEquals(3, snapshot.size()); + assertEquals(VfsLayer.MOD_LOOSE, snapshot.get(1).layer()); + } + + @Test + public void listsOnlyExplicitlyMountedArchiveEntries() throws Exception { + Path pak = temp.resolve("archive/content.pak"); + writePak(pak, Map.of("nested/Z.txt", "zip".getBytes(StandardCharsets.US_ASCII), + "a.txt", "a".getBytes(StandardCharsets.US_ASCII))); + writeFile(pak.resolveSibling("outside.cfg"), "outside"); + VirtualFileSystem vfs = new DefaultVirtualFileSystem(); + vfs.configure(config(null, null, false, false, List.of())); + VfsResult mount = vfs.mountPackage(pak.toString()); + assertTrue(mount.success()); + + List snapshot = vfs.listEntries(); + + assertEquals(List.of("a.txt", "nested/z.txt"), snapshot.stream().map(VfsEntry::normalizedPath).toList()); + VfsEntry nested = snapshot.get(1); + assertEquals("nested/Z.txt", nested.displayPath()); + assertEquals(3, nested.size()); + assertEquals(VfsSourceType.PACKAGE_ENTRY, nested.source().type()); + assertEquals(pak, nested.source().containerPath()); + assertTrue(vfs.unmount(mount.value()).success()); + assertTrue(vfs.listEntries().isEmpty()); + assertEquals(2, snapshot.size()); + } + private VfsConfig config( Path basedir, String gameMod, diff --git a/settings.gradle.kts b/settings.gradle.kts index fbd27d39c..646a917c9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,7 +24,7 @@ include (":maptools") include (":cake") include (":cake:core") include (":cake:cake-client") -include (":cake:cake-modelviewer") +include (":cake:cake-viewer") include (":cake:engine-tools") include(":cake:desktop-common")