diff --git a/.gitignore b/.gitignore index dd32a51..150927f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,9 @@ local.properties /SchwertUndMagieOnPebbleCompanionApp/.idea/assetWizardSettings.xml /SchwertUndMagieOnPebbleCompanionApp/build /SchwertUndMagieOnPebbleCompanionApp/captures +/SchwertUndMagieOnPebbleCompanionApp/app/build +/SchwertUndMagieOnPebbleCompanionApp/app/.cxx +/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src +/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs /SchwertUndMagieOnPebbleWatchApp/build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22801ad --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project goal + +Port the classic German C64 RPG series **Schwert und Magie** (8 episodes, German Design Group, 1989–1992) to a Pebble Time 2 smartwatch. The phone runs a VICE C64 emulator as a companion app; the watch displays the game and sends button presses back. + +## Repository layout + +``` +SchwertUndMagieOnPebbleWatchApp/ Pebble watchapp (C + PebbleKit JS) +SchwertUndMagieOnPebbleCompanionApp/ Android companion app (Kotlin + NDK) +versions/ Original .d64 disk images (4 disks, 8 episodes) +docs/ Background notes +``` + +## Watch app + +### Build & install + +```bash +cd SchwertUndMagieOnPebbleWatchApp +pebble build # all platforms +pebble install --phone # sideload via Pebble/Core app on the phone +pebble logs --phone # stream JS + C app logs +``` + +In headless / CI environments, add `--vnc` to every emulator command: +```bash +pebble install --emulator emery --vnc +pebble screenshot --vnc --scale 6 --no-open screenshot.png +pebble emu-button --emulator emery --vnc click select +``` + +### Key files + +| File | Purpose | +|---|---| +| `src/c/SchwertUndMagieOnPebbleFrontend.c` | Single-file C watchapp | +| `src/pkjs/index.js` | PebbleKit JS — runs inside Core for Pebble on the phone | +| `package.json` | UUID, target platforms, `messageKeys` | + +### Communication protocol + +AppMessage keys (defined in both C and JS): + +| Key | Direction | Value | +|---|---|---| +| `0` (TIME) | phone → watch | `"HH:mm:ss"` string (later: C64 framebuffer) | +| `1` (COMMAND) | watch → phone | button name string (`"UP"`, `"DOWN"`, `"SELECT"`) | + +The watch UUID is `8039ba8c-f1e8-4620-838d-650fa35335b7`. + +### PebbleKit JS notes (Core for Pebble quirks) + +- **Use `127.0.0.1` not `localhost`** — Core for Pebble's JS runtime does not resolve `localhost`. +- **Use numeric keys in `sendAppMessage`** (`{ 0: value }` not `{ 'TIME': value }`) — symbolic key resolution from `package.json` messageKeys is unreliable with Core for Pebble. +- PebbleKit Android (`com.getpebble.android.*`) does **not** work with Core for Pebble; all phone↔watch communication goes through PebbleKit JS + the HTTP server. + +## Android companion app + +### Build + +Open `SchwertUndMagieOnPebbleCompanionApp/` in Android Studio, or: + +```bash +cd SchwertUndMagieOnPebbleCompanionApp +./gradlew assembleDebug +./gradlew installDebug +``` + +Android SDK is at `/opt/android-sdk`. + +### Communication architecture + +``` +Watch (AppMessage BT) + ↕ +PebbleKit JS — index.js runs inside Core for Pebble + ↕ HTTP on 127.0.0.1:8888 +Android companion app — NanoHTTPD server + GET /time → {"time":"HH:mm:ss"} + GET /key?cmd=UP → logs keystroke +``` + +`MainActivity` starts both the NanoHTTPD server and the VICE emulator loop thread. All HTTP callbacks marshal to the main thread via `mainHandler`. + +### VICE integration (NDK) + +VICE 3.8 tarball is at `SchwertUndMagieOnPebbleCompanionApp/res/vice-3.8.tar.gz`. + +**Without VICE built** (first state): `vice_jni.c` fills the framebuffer with a placeholder blue screen; the app builds and runs normally. + +**VICE compilation is automatic.** The Gradle `buildVice` task runs before every native CMake build. On the first build it: +1. Calls `app/src/main/jni/build_vice.sh` with `$NDK` set from the Android Studio SDK config +2. The script unpacks the tarball, cross-compiles VICE headless for ARM64 and x86_64, and produces `vice-libs//libvice.a` +3. `CMakeLists.txt` auto-detects the library via `EXISTS` and links it in — no manual flags needed + +The NDK must be installed: **Android Studio → SDK Manager → SDK Tools → NDK (Side by side)**. + +After the first successful build, subsequent builds skip the VICE compilation step entirely (outputs are up-to-date). + +**C64 ROM files** must be pushed to the device separately (not redistributable). +VICE expects files named exactly `kernal`, `basic`, `chargen` (no extensions) in the +app's private `filesDir`: `/data/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/` + +```bash +adb push kernal /data/local/tmp/kernal +adb push basic /data/local/tmp/basic +adb push chargen /data/local/tmp/chargen +adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/kernal files/kernal +adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/basic files/basic +adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/chargen files/chargen +``` + +### Key Kotlin/C files + +| File | Purpose | +|---|---| +| `MainActivity.kt` | Emulator loop thread (50 fps), NanoHTTPD server, keyboard event logging | +| `C64Engine.kt` | JNI interface to VICE — init, runFrame, getVideoBuffer, injectKey, loadDisk | +| `C64DisplayView.kt` | SurfaceView — blits 320×200 ARGB framebuffer, scaled with correct aspect ratio | +| `C64KeyboardView.kt` | Multi-touch virtual C64 keyboard; fires `KeyEventListener` on press/release | +| `jni/vice_jni.c` | JNI wrapper — custom VICE video canvas writes frames to `g_framebuf[320×200]` | +| `jni/CMakeLists.txt` | NDK build; conditionally links `libvice.a` when `HAVE_VICE_SRC=1` | + +### Disk images + +The four original `.d64` disk images are in `versions/`. Load via: +```kotlin +engine.loadDisk(filesDir.absolutePath + "/schwert_und_magie_1.d64") +``` +(Copy the relevant `.d64` to `filesDir` first via `adb push`.) diff --git a/SchwertUndMagieOnPebbleCompanionApp/.idea/vcs.xml b/SchwertUndMagieOnPebbleCompanionApp/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts index 7428246..2c9580b 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts +++ b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) } @@ -10,6 +12,8 @@ android { } } + ndkVersion = "30.0.14904198" + defaultConfig { applicationId = "de.ladkau.schwertundmagieonpebblecompanionapp" minSdk = 24 @@ -18,6 +22,17 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + externalNativeBuild { + cmake { + arguments("-DANDROID_STL=c++_shared") + // HAVE_VICE_SRC is detected automatically by CMakeLists.txt + // based on whether vice-libs//libvice.a exists. + } + } } buildTypes { @@ -33,6 +48,86 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + externalNativeBuild { + cmake { + path = file("src/main/jni/CMakeLists.txt") + version = "3.22.1" + } + } +} + +// --------------------------------------------------------------------------- +// VICE cross-compilation task +// +// Runs automatically before any CMake build step. +// Skipped entirely if vice-libs//libvice.a already exists. +// The tarball at /res/vice-3.8.tar.gz is unpacked by build_vice.sh. +// --------------------------------------------------------------------------- + +val viceLibArm = layout.projectDirectory.file("src/main/jni/vice-libs/arm64-v8a/libvice.a") +val viceLibX86 = layout.projectDirectory.file("src/main/jni/vice-libs/x86_64/libvice.a") +val viceTarball = layout.projectDirectory.file("../res/vice-3.8.tar.gz") + +tasks.register("buildVice") { + group = "build" + description = "Cross-compile VICE 3.8 headless for Android (ARM64 + x86_64)" + + inputs.file(viceTarball) + outputs.file(viceLibArm) + outputs.file(viceLibX86) + + doLast { + if (viceLibArm.asFile.exists() && viceLibX86.asFile.exists()) { + logger.lifecycle("VICE already built — skipping") + return@doLast + } + + val localProps = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() } + ?.reader()?.use { load(it) } + } + val sdkDir = localProps.getProperty("sdk.dir") + ?: System.getenv("ANDROID_HOME") + ?: "" + val ndkPath = localProps.getProperty("ndk.dir") + ?: System.getenv("ANDROID_NDK_HOME") + ?: System.getenv("ANDROID_NDK_ROOT") + ?: System.getenv("NDK") + ?: sdkDir.takeIf { it.isNotEmpty() }?.let { sdk -> + // NDK side-by-side: pick the first installed version + file("$sdk/ndk").takeIf { it.isDirectory } + ?.listFiles()?.sorted()?.lastOrNull()?.absolutePath + ?: "$sdk/ndk-bundle".takeIf { file("$sdk/ndk-bundle").isDirectory } + } + ?: throw GradleException( + "Android NDK not found.\n" + + "Install via: Android Studio → SDK Manager → SDK Tools → NDK (Side by side)" + ) + logger.lifecycle("Building VICE with NDK at $ndkPath …") + + val proc = ProcessBuilder("bash", "build_vice.sh") + .directory(layout.projectDirectory.dir("src/main/jni").asFile) + .redirectErrorStream(true) + .also { it.environment()["NDK"] = ndkPath } + .start() + proc.inputStream.bufferedReader().forEachLine { logger.lifecycle(it) } + val exit = proc.waitFor() + if (exit != 0) throw GradleException("build_vice.sh failed (exit $exit)") + + // Touch CMakeLists.txt so CMake re-evaluates the EXISTS check on the + // next build and picks up the newly created libvice.a. + layout.projectDirectory.file("src/main/jni/CMakeLists.txt") + .asFile.setLastModified(System.currentTimeMillis()) + logger.lifecycle("VICE build complete — CMakeLists.txt touched for re-evaluation") + } +} + +// Run buildVice before any CMake configure or build step. +tasks.whenTaskAdded { + if (name.startsWith("configureCMake") || name.startsWith("buildCMake") || + name.startsWith("externalNativeBuild")) { + dependsOn("buildVice") + } } dependencies { diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt new file mode 100644 index 0000000..2b107bc --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt @@ -0,0 +1,59 @@ +package de.ladkau.schwertundmagieonpebblecompanionapp + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.util.AttributeSet +import android.view.SurfaceHolder +import android.view.SurfaceView +import java.nio.ByteBuffer + +/** + * Renders the VICE 320×200 ARGB framebuffer into a SurfaceView, + * scaled to fill the view while keeping the 8:5 aspect ratio. + */ +class C64DisplayView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : SurfaceView(context, attrs), SurfaceHolder.Callback { + + private val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888) + private val pixelBuf: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4) + private val srcRect = Rect(0, 0, 320, 200) + private var dstRect = Rect() + + init { + holder.addCallback(this) + } + + /** Called from the emulator thread after every runFrame(). */ + fun updateFrame(engine: C64Engine) { + pixelBuf.rewind() + engine.getVideoBuffer(pixelBuf) + pixelBuf.rewind() + bitmap.copyPixelsFromBuffer(pixelBuf) + + val canvas: Canvas = holder.lockCanvas() ?: return + try { + canvas.drawBitmap(bitmap, srcRect, dstRect, null) + } finally { + holder.unlockCanvasAndPost(canvas) + } + } + + override fun surfaceCreated(h: SurfaceHolder) = recalcDst() + override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, h2: Int) = recalcDst() + override fun surfaceDestroyed(h: SurfaceHolder) {} + + private fun recalcDst() { + val vw = width.toFloat() + val vh = height.toFloat() + val scale = minOf(vw / 320f, vh / 200f) + val sw = (320 * scale).toInt() + val sh = (200 * scale).toInt() + val ox = ((vw - sw) / 2).toInt() + val oy = ((vh - sh) / 2).toInt() + dstRect = Rect(ox, oy, ox + sw, oy + sh) + } +} diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt new file mode 100644 index 0000000..5994b7e --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt @@ -0,0 +1,66 @@ +package de.ladkau.schwertundmagieonpebblecompanionapp + +import java.nio.ByteBuffer + +/** + * JNI bridge to the VICE headless C64 emulator. + * + * The native library is built from app/src/main/jni/ using the Android NDK. + * VICE source must be placed at app/src/main/jni/vice-src/ before building + * (download from https://vice-emu.sourceforge.io/). + * + * ROM files (kernal, basic, chargen) must be copied to the app's filesDir + * before calling initEmulator(). + */ +class C64Engine { + + /** Initialize VICE. romDir should contain kernal, basic, chargen. */ + external fun initEmulator(romDir: String): Boolean + + /** Mount a .d64/.t64 disk image to drive 8. */ + external fun loadDisk(path: String): Boolean + + /** Run the emulator for one video frame (~20 000 CPU cycles). */ + external fun runFrame() + + /** + * Copy the current 320×200 ARGB frame into [buffer]. + * The buffer must have capacity >= 320 * 200 * 4 bytes. + */ + external fun getVideoBuffer(buffer: ByteBuffer) + + /** + * Inject a C64 keyboard event. + * [keyCode] uses VICE's row/column encoding: (row << 8) | col (0-based). + */ + external fun injectKey(keyCode: Int, pressed: Boolean) + + companion object { + init { System.loadLibrary("vice_jni") } + + // Convenient key-code constants (row << 8 | col in C64 matrix) + const val KEY_1 = (0 shl 8) or 0 + const val KEY_2 = (0 shl 8) or 3 + const val KEY_3 = (1 shl 8) or 0 + const val KEY_4 = (1 shl 8) or 3 + const val KEY_5 = (2 shl 8) or 0 + const val KEY_6 = (2 shl 8) or 3 + const val KEY_7 = (3 shl 8) or 0 + const val KEY_8 = (3 shl 8) or 3 + const val KEY_9 = (4 shl 8) or 0 + const val KEY_0 = (4 shl 8) or 3 + const val KEY_Q = (6 shl 8) or 6 + const val KEY_W = (1 shl 8) or 1 + const val KEY_E = (0 shl 8) or 6 + const val KEY_R = (1 shl 8) or 9 // placeholder — real values from VICE keytable + const val KEY_Y = (6 shl 8) or 1 + const val KEY_N = (4 shl 8) or 7 + const val KEY_RETURN = (0 shl 8) or 1 + const val KEY_SPACE = (7 shl 8) or 4 + const val KEY_F1 = (0 shl 8) or 4 + const val KEY_F3 = (0 shl 8) or 5 + const val KEY_F5 = (0 shl 8) or 6 + const val KEY_F7 = (0 shl 8) or 3 + const val KEY_RUNSTOP = (7 shl 8) or 7 + } +} diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt new file mode 100644 index 0000000..a8bfd44 --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt @@ -0,0 +1,135 @@ +package de.ladkau.schwertundmagieonpebblecompanionapp + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +data class C64Key(val label: String, val code: Int, val widthWeight: Float = 1f) + +/** Callback fired when a key is pressed or released. */ +typealias KeyEventListener = (key: C64Key, pressed: Boolean) -> Unit + +/** + * Virtual C64 keyboard. Draws four rows of keys and fires [onKeyEvent] + * on touch down / up. + */ +class C64KeyboardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + var onKeyEvent: KeyEventListener? = null + + private val rows: List> = listOf( + listOf( + C64Key("F1", C64Engine.KEY_F1), C64Key("F3", C64Engine.KEY_F3), + C64Key("F5", C64Engine.KEY_F5), C64Key("F7", C64Engine.KEY_F7), + C64Key("RUN\nSTOP", C64Engine.KEY_RUNSTOP, 1.5f) + ), + listOf( + C64Key("1", C64Engine.KEY_1), C64Key("2", C64Engine.KEY_2), + C64Key("3", C64Engine.KEY_3), C64Key("4", C64Engine.KEY_4), + C64Key("5", C64Engine.KEY_5), C64Key("6", C64Engine.KEY_6), + C64Key("7", C64Engine.KEY_7), C64Key("8", C64Engine.KEY_8), + C64Key("9", C64Engine.KEY_9), C64Key("0", C64Engine.KEY_0) + ), + listOf( + C64Key("Q", C64Engine.KEY_Q), C64Key("W", C64Engine.KEY_W), + C64Key("E", C64Engine.KEY_E), C64Key("R", C64Engine.KEY_R), + C64Key("Y", C64Engine.KEY_Y), C64Key("N", C64Engine.KEY_N), + C64Key("RETURN", C64Engine.KEY_RETURN, 2f) + ), + listOf( + C64Key("SPACE", C64Engine.KEY_SPACE, 6f) + ) + ) + + // Map pointer-id → key currently held down by that finger + private val heldKeys = mutableMapOf() + + private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#2C2C6C") + } + private val pressedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#6464C0") + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + } + + // Pre-computed key rectangles for hit-testing: rowIndex → list of (rect, key) + private var keyRects: List>> = emptyList() + + override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { + super.onSizeChanged(w, h, oldW, oldH) + buildKeyRects(w.toFloat(), h.toFloat()) + } + + private fun buildKeyRects(totalW: Float, totalH: Float) { + val rowH = totalH / rows.size + val pad = 4f + keyRects = rows.mapIndexed { rowIdx, row -> + val totalUnits = row.sumOf { it.widthWeight.toDouble() }.toFloat() + val unitW = totalW / totalUnits + var x = 0f + row.map { key -> + val kw = key.widthWeight * unitW + val rect = RectF(x + pad, rowIdx * rowH + pad, x + kw - pad, (rowIdx + 1) * rowH - pad) + x += kw + rect to key + } + } + } + + override fun onDraw(canvas: Canvas) { + val held = heldKeys.values.toSet() + textPaint.textSize = height / (rows.size * 2.5f) + + for (row in keyRects) { + for ((rect, key) in row) { + val paint = if (key in held) pressedPaint else keyPaint + canvas.drawRoundRect(rect, 6f, 6f, paint) + val ty = rect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2 + canvas.drawText(key.label, rect.centerX(), ty, textPaint) + } + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val idx = event.actionIndex + val pid = event.getPointerId(idx) + val ex = event.getX(idx) + val ey = event.getY(idx) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val key = keyAt(ex, ey) ?: return true + heldKeys[pid] = key + onKeyEvent?.invoke(key, true) + invalidate() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_CANCEL -> { + val key = heldKeys.remove(pid) ?: return true + onKeyEvent?.invoke(key, false) + invalidate() + } + } + return true + } + + private fun keyAt(x: Float, y: Float): C64Key? { + for (row in keyRects) { + for ((rect, key) in row) { + if (rect.contains(x, y)) return key + } + } + return null + } +} diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt index 5ce6dc2..65fc5d2 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt @@ -13,59 +13,112 @@ import java.util.Date import java.util.Locale private const val PORT = 8888 -private const val TAG = "SuM" +private const val TAG = "SuM" class MainActivity : AppCompatActivity() { - private lateinit var tvStatus: TextView - private lateinit var tvTime: TextView - private lateinit var tvLog: TextView + private lateinit var tvStatus: TextView + private lateinit var tvTime: TextView + private lateinit var tvLog: TextView private lateinit var scrollLog: ScrollView + private lateinit var display: C64DisplayView + private lateinit var keyboard: C64KeyboardView private val mainHandler = Handler(Looper.getMainLooper()) - private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - private var server: CompanionServer? = null + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + + private var server: CompanionServer? = null + private val engine = C64Engine() + private var emuThread: Thread? = null + @Volatile private var emuRunning = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - tvStatus = findViewById(R.id.tv_status) - tvTime = findViewById(R.id.tv_time) - tvLog = findViewById(R.id.tv_log) + tvStatus = findViewById(R.id.tv_status) + tvTime = findViewById(R.id.tv_time) + tvLog = findViewById(R.id.tv_log) scrollLog = findViewById(R.id.scroll_log) + display = findViewById(R.id.c64_display) + keyboard = findViewById(R.id.c64_keyboard) + initEmulator() + startHttpServer() + wireKeyboard() + } + + // ---- emulator ------------------------------------------------------- + + private fun initEmulator() { + val romDir = filesDir.absolutePath + val ok = engine.initEmulator(romDir) + tvStatus.text = if (ok) "Emulator ready" else "Emulator: no ROMs (stub mode)" + Log.d(TAG, "initEmulator=$ok romDir=$romDir") + + emuRunning = true + emuThread = Thread { + while (emuRunning) { + val t0 = System.currentTimeMillis() + engine.runFrame() + display.updateFrame(engine) + val elapsed = System.currentTimeMillis() - t0 + // Target ~50 fps (20 ms/frame) + if (elapsed < 20) Thread.sleep(20 - elapsed) + } + }.also { it.name = "emu-loop"; it.isDaemon = true; it.start() } + } + + // ---- virtual keyboard ----------------------------------------------- + + private fun wireKeyboard() { + keyboard.onKeyEvent = { key, pressed -> + engine.injectKey(key.code, pressed) + if (pressed) { + Log.d(TAG, "Key pressed: ${key.label}") + mainHandler.post { appendLog("KEY: ${key.label}") } + } + } + } + + // ---- HTTP server (watch bridge) ------------------------------------- + + private fun startHttpServer() { server = CompanionServer(PORT, timeFormat, onTimeFetched = { time -> mainHandler.post { tvTime.text = "Serving time: $time" } }, onKey = { cmd -> - Log.d(TAG, "Key received: $cmd") - mainHandler.post { appendLog(cmd) } + Log.d(TAG, "Watch key: $cmd") + mainHandler.post { appendLog("WATCH: $cmd") } } ) try { server?.start() - tvStatus.text = "Listening on localhost:$PORT" Log.d(TAG, "HTTP server started on port $PORT") } catch (e: Exception) { - tvStatus.text = "Server error: ${e.message}" Log.e(TAG, "Failed to start server", e) } } + // ---- helpers -------------------------------------------------------- + private fun appendLog(entry: String) { - val timestamp = timeFormat.format(Date()) - tvLog.text = "[$timestamp] $entry\n${tvLog.text}" + val ts = timeFormat.format(Date()) + tvLog.text = "[$ts] $entry\n${tvLog.text}" scrollLog.post { scrollLog.scrollTo(0, 0) } } override fun onDestroy() { - super.onDestroy() + emuRunning = false + emuThread?.join(500) server?.stop() + super.onDestroy() } } +// --------------------------------------------------------------------------- + class CompanionServer( port: Int, private val timeFormat: SimpleDateFormat, @@ -74,15 +127,12 @@ class CompanionServer( ) : NanoHTTPD(port) { override fun serve(session: IHTTPSession): Response { - Log.d(TAG, "HTTP ${session.method} ${session.uri} params=${session.parameters}") val response = when (session.uri) { "/time" -> { val time = timeFormat.format(Date()) onTimeFetched(time) newFixedLengthResponse( - Response.Status.OK, - "application/json", - """{"time":"$time"}""" + Response.Status.OK, "application/json", """{"time":"$time"}""" ) } "/key" -> { @@ -90,9 +140,7 @@ class CompanionServer( if (cmd.isNotEmpty()) onKey(cmd) newFixedLengthResponse(Response.Status.OK, "text/plain", "ok") } - else -> newFixedLengthResponse( - Response.Status.NOT_FOUND, "text/plain", "not found" - ) + else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found") } response.addHeader("Access-Control-Allow-Origin", "*") return response diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/CMakeLists.txt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..d5763a3 --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.22) +project(vice_jni C) + +# Auto-detect whether libvice.a was produced by the Gradle buildVice task. +# No manual flag needed — just build the project and Gradle handles the rest. +set(VICE_LIB "${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}/libvice.a") +set(VICE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/vice-src") + +if(EXISTS "${VICE_LIB}") + set(HAVE_VICE ON) + message(STATUS "Found ${VICE_LIB} — building with full VICE emulation") +else() + set(HAVE_VICE OFF) + message(STATUS "No libvice.a found — building stub (placeholder framebuffer)") +endif() + +# ---- JNI wrapper library ------------------------------------------------- +add_library(vice_jni SHARED vice_jni.c) + +target_link_libraries(vice_jni android log c++_shared z) + +if(HAVE_VICE) + target_compile_definitions(vice_jni PRIVATE HAVE_VICE_SRC=1) + target_include_directories(vice_jni PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}" + "${VICE_SRC}/src" + "${VICE_SRC}/src/arch/headless" + "${VICE_SRC}/src/arch/shared" + "${VICE_SRC}/src/c64" + "${VICE_SRC}/src/drive" + "${VICE_SRC}/src/lib/p64" + "${VICE_SRC}/src/lib" + ) + target_link_libraries(vice_jni "${VICE_LIB}") +endif() diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/build_vice.sh b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/build_vice.sh new file mode 100644 index 0000000..b261a8b --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/build_vice.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Cross-compile VICE 3.8 headless as a static library for Android. +# Called automatically by Gradle; can also be run manually: +# export NDK=/path/to/android-ndk && bash build_vice.sh +# +# Output: vice-libs/arm64-v8a/libvice.a +# vice-libs/x86_64/libvice.a + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARBALL="${SCRIPT_DIR}/../../../../res/vice-3.8.tar.gz" +SRC="${SCRIPT_DIR}/vice-src" + +: "${NDK:?NDK env var must point to the Android NDK root}" + +echo "=== build_vice.sh ===" +echo " SCRIPT_DIR : ${SCRIPT_DIR}" +echo " TARBALL : ${TARBALL}" +echo " SRC : ${SRC}" +echo " NDK : ${NDK}" + +# ---- Validate NDK toolchain ----------------------------------------------- +TOOLCHAIN="${NDK}/toolchains/llvm/prebuilt/linux-x86_64" +if [ ! -d "${TOOLCHAIN}" ]; then + echo "ERROR: NDK toolchain not found at ${TOOLCHAIN}" >&2 + echo " Installed NDK contents:" >&2 + ls "${NDK}" 2>&1 || true + exit 1 +fi +echo " TOOLCHAIN : ${TOOLCHAIN} [OK]" + +# ---- Ensure required host tools are present --------------------------------- +MISSING=() +for tool in dos2unix autoconf automake pkg-config xa; do + command -v "$tool" &>/dev/null || MISSING+=("$tool") +done +if [ ${#MISSING[@]} -gt 0 ]; then + echo "ERROR: Missing required build tools: ${MISSING[*]}" >&2 + echo " Install with: sudo apt-get install -y ${MISSING[*]/xa/xa65}" >&2 + exit 1 +fi + +# ---- Validate tarball ------------------------------------------------------- +if [ ! -f "${TARBALL}" ]; then + echo "ERROR: VICE tarball not found at ${TARBALL}" >&2 + exit 1 +fi +echo " TARBALL found [OK]" + +# ---- Unpack once ------------------------------------------------------------ +if [ ! -d "${SRC}" ]; then + echo "Unpacking VICE 3.8..." + tar -xzf "${TARBALL}" -C "${SCRIPT_DIR}" + mv "${SCRIPT_DIR}/vice-3.8" "${SRC}" + echo "Unpacked to ${SRC}" +fi + +# ---- Run autogen if configure is missing ------------------------------------ +if [ ! -f "${SRC}/configure" ]; then + echo "Running autogen.sh..." + pushd "${SRC}" >/dev/null + ./autogen.sh + popd >/dev/null +fi + +# ---- Build per ABI ---------------------------------------------------------- +build_abi() { + local ABI="$1" + local HOST="$2" + local API=24 + local CC="${TOOLCHAIN}/bin/${HOST}${API}-clang" + local CXX="${TOOLCHAIN}/bin/${HOST}${API}-clang++" + local AR="${TOOLCHAIN}/bin/llvm-ar" + local OUT="${SCRIPT_DIR}/vice-libs/${ABI}" + local BUILD_DIR="/tmp/vice-android-${ABI}" + + echo "" + echo "--- ABI: ${ABI} ---" + echo " CC : ${CC}" + echo " CXX : ${CXX}" + echo " AR : ${AR}" + echo " OUT : ${OUT}" + + if [ -f "${OUT}/libvice.a" ] && [ -f "${OUT}/config.h" ]; then + echo " libvice.a + config.h already exist — skipping" + return 0 + fi + + if [ ! -f "${CC}" ]; then + echo "ERROR: Clang not found at ${CC}" >&2 + echo " Available clang binaries:" >&2 + ls "${TOOLCHAIN}/bin/"*clang* 2>&1 || true + exit 1 + fi + if [ ! -f "${CXX}" ]; then + echo "ERROR: Clang++ not found at ${CXX}" >&2 + ls "${TOOLCHAIN}/bin/"*clang* 2>&1 || true + exit 1 + fi + + mkdir -p "${BUILD_DIR}" "${OUT}" + pushd "${BUILD_DIR}" >/dev/null + + echo " Configuring VICE..." + CC="${CC}" CXX="${CXX}" AR="${AR}" \ + CFLAGS="-fPIC" CXXFLAGS="-fPIC" \ + "${SRC}/configure" \ + --host="${HOST}" \ + --srcdir="${SRC}" \ + --enable-headlessui \ + --without-libcurl \ + --without-png \ + --without-alsa \ + --without-pulse \ + --without-libieee1284 \ + --enable-arch=no \ + --disable-dependency-tracking \ + --disable-debug + + echo " Compiling VICE sub-libraries..." + # Build all sub-libraries; tolerate failure of the final x64 link step + # (which may fail due to optional deps like linenoise-ng not being a sub-lib dep) + make -C src -j"$(nproc)" || echo " NOTE: make exited non-zero — sub-libraries are still usable" + + echo " Archiving into libvice.a..." + OBJ_TMPDIR=$(mktemp -d) + CTR=0 + + # 1. Standalone .o files compiled directly in BUILD_DIR/src/ + # (core objects: alarm.o keyboard.o attach.o etc.) + for obj in "${BUILD_DIR}/src"/*.o; do + [ -f "$obj" ] || continue + base=$(basename "$obj") + case "$base" in + vsyncapi.o|c1541-stubs.o|vsid-stubs.o) continue ;; + esac + cp "$obj" "${OBJ_TMPDIR}/${CTR}_${base}" + CTR=$((CTR+1)) + done + + # 2. Extract objects from sub-library .a files, skipping non-C64 machines + while IFS= read -r alib; do + libname=$(basename "${alib}" .a) + extract_dir=$(mktemp -d) + pushd "${extract_dir}" >/dev/null + "${AR}" -x "${alib}" 2>/dev/null || true + for obj in *.o; do + [ -f "$obj" ] || continue + case "$obj" in + main.o|vsyncapi.o|c1541-stubs.o|vsid-stubs.o) continue ;; + esac + # Skip headless video.o — provided by vice_jni.c + if [[ "${alib}" == *headless* ]] && [ "$obj" = "video.o" ]; then continue; fi + cp "$obj" "${OBJ_TMPDIR}/${CTR}_${libname}_${obj}" + CTR=$((CTR+1)) + done + popd >/dev/null + rm -rf "${extract_dir}" + done < <(find "${BUILD_DIR}/src" -name "*.a" \ + ! -path "*/c128/*" ! -path "*/scpu64/*" ! -path "*/c64dtv/*" \ + ! -path "*/cbm2/*" ! -path "*/pet/*" ! -path "*/plus4/*" \ + ! -path "*/vic20/*" ! -path "*/viciisc/*" \ + | sort) + + OBJ_COUNT=$(find "${OBJ_TMPDIR}" -name "*.o" | wc -l) + echo " objects collected: ${OBJ_COUNT}" + if [ "${OBJ_COUNT}" -eq 0 ]; then + echo "ERROR: No object files collected — sub-library build may have failed" >&2 + rm -rf "${OBJ_TMPDIR}" + exit 1 + fi + "${AR}" -crs "${OUT}/libvice.a" "${OBJ_TMPDIR}"/*.o + rm -rf "${OBJ_TMPDIR}" + + CONFIG_H=$(find "${BUILD_DIR}" -name "config.h" 2>/dev/null | head -1) + if [ -z "${CONFIG_H}" ]; then + echo "ERROR: config.h not found under ${BUILD_DIR}" >&2 + exit 1 + fi + cp "${CONFIG_H}" "${OUT}/config.h" + echo " Built ${OUT}/libvice.a ($(du -sh "${OUT}/libvice.a" | cut -f1))" + popd >/dev/null +} + +build_abi "arm64-v8a" "aarch64-linux-android" +build_abi "x86_64" "x86_64-linux-android" + +echo "" +echo "=== VICE build complete ===" diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c new file mode 100644 index 0000000..6e5844b --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c @@ -0,0 +1,181 @@ +/* + * JNI bridge for VICE headless C64 emulation. + * + * Without VICE source (stub mode): + * initEmulator fills g_framebuf with a solid C64-blue — the app builds + * and runs normally; the display shows a placeholder. + * + * With VICE source (HAVE_VICE_SRC=1, set automatically by CMakeLists.txt + * when vice-libs//libvice.a exists): + * VICE runs in its own thread started by initEmulator(). + * The custom video canvas callback writes each frame into g_framebuf. + * getVideoBuffer() snapshots g_framebuf into the Java ByteBuffer. + * injectKey() writes directly to VICE's keyboard matrix. + */ + +#include +#include +#include +#include +#include + +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "ViceJNI", __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "ViceJNI", __VA_ARGS__) + +#define FRAME_W 320 +#define FRAME_H 200 + +static uint32_t g_framebuf[FRAME_W * FRAME_H]; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_ready = 0; + +/* ========================================================================= + * VICE integration (compiled only when libvice.a is linked in) + * ========================================================================= */ +#ifdef HAVE_VICE_SRC +#include "vice.h" +#include "main.h" +#include "machine.h" +#include "videoarch.h" +#include "video.h" +#include "keyboard.h" +#include "attach.h" + +/* Stubs for symbols that live in arch-specific or excluded files. */ +void main_exit(void) { LOGI("main_exit called — ignoring in JNI context"); } + +/* All functions below replace arch/headless/video.c (excluded from libvice.a). */ + +int video_arch_get_active_chip(void) { return 0; /* VIDEO_CHIP_VICII */ } +void video_arch_canvas_init(video_canvas_t *c) { (void)c; } +int video_arch_cmdline_options_init(void) { return 0; } +int video_arch_resources_init(void) { return 0; } +void video_arch_resources_shutdown(void) {} +char video_canvas_can_resize(video_canvas_t *c) { (void)c; return 0; } +void video_canvas_destroy(video_canvas_t *c) { (void)c; } +void video_canvas_resize(video_canvas_t *c, char r){ (void)c; (void)r; } +int video_init(void) { return 0; } +void video_shutdown(void) {} + +int video_canvas_set_palette(video_canvas_t *canvas, struct palette_s *palette) { + canvas->palette = palette; + return 0; +} + +video_canvas_t *video_canvas_create(video_canvas_t *canvas, + unsigned int *width, unsigned int *height, + int mapped) { + (void)mapped; + canvas->created = 1; + *width = FRAME_W; + *height = FRAME_H; + return canvas; +} + +/* Called by VICE every time a frame is ready — copy pixels into g_framebuf. */ +void video_canvas_refresh(video_canvas_t *canvas, + unsigned int xs, unsigned int ys, + unsigned int xi, unsigned int yi, + unsigned int w, unsigned int h) { + if (!canvas->draw_buffer || !canvas->draw_buffer->draw_buffer) return; + + pthread_mutex_lock(&g_lock); + const uint8_t *src = canvas->draw_buffer->draw_buffer + + ys * canvas->draw_buffer->draw_buffer_pitch + + xs * 4; /* 32 bpp = 4 bytes/pixel */ + uint32_t *dst = g_framebuf + yi * FRAME_W + xi; + for (unsigned int row = 0; row < h; row++) { + memcpy(dst, src, w * 4); + src += canvas->draw_buffer->draw_buffer_pitch; + dst += FRAME_W; + } + pthread_mutex_unlock(&g_lock); +} + +/* VICE main loop runs in this thread. */ +static char *g_vice_argv[8]; +static int g_vice_argc; + +static void *vice_thread(void *arg) { + (void)arg; + main_program(g_vice_argc, g_vice_argv); + return NULL; +} +#endif /* HAVE_VICE_SRC */ + +/* ========================================================================= + * JNI entry points + * ========================================================================= */ +#define JNI_FN(ret, name) \ + JNIEXPORT ret JNICALL \ + Java_de_ladkau_schwertundmagieonpebblecompanionapp_C64Engine_##name + +JNI_FN(jboolean, initEmulator)(JNIEnv *env, jobject obj, jstring romDir) { +#ifdef HAVE_VICE_SRC + const char *dir = (*env)->GetStringUTFChars(env, romDir, NULL); + LOGI("initEmulator: romDir=%s", dir); + + /* Prepare argv for VICE: x64 -directory -headless */ + static char dir_copy[512]; + strncpy(dir_copy, dir, sizeof(dir_copy) - 1); + (*env)->ReleaseStringUTFChars(env, romDir, dir); + + g_vice_argc = 4; + g_vice_argv[0] = "x64"; + g_vice_argv[1] = "-directory"; + g_vice_argv[2] = dir_copy; + g_vice_argv[3] = "-silent"; + g_vice_argv[4] = NULL; + + pthread_t tid; + int rc = pthread_create(&tid, NULL, vice_thread, NULL); + pthread_detach(tid); + g_ready = (rc == 0); + return (jboolean)g_ready; +#else + (void)env; (void)obj; (void)romDir; + /* Fill with C64-blue placeholder so the UI works without VICE. */ + for (int i = 0; i < FRAME_W * FRAME_H; i++) + g_framebuf[i] = 0xFF6464C0u; + g_ready = 1; + return JNI_TRUE; +#endif +} + +JNI_FN(jboolean, loadDisk)(JNIEnv *env, jobject obj, jstring path) { + (void)obj; +#ifdef HAVE_VICE_SRC + const char *p = (*env)->GetStringUTFChars(env, path, NULL); + int rc = file_system_attach_disk(8, 0, p); + (*env)->ReleaseStringUTFChars(env, path, p); + return (jboolean)(rc == 0); +#else + (void)env; (void)path; + return JNI_FALSE; +#endif +} + +/* In thread mode runFrame() is a no-op: VICE drives its own loop. */ +JNI_FN(void, runFrame)(JNIEnv *env, jobject obj) { + (void)env; (void)obj; +} + +JNI_FN(void, getVideoBuffer)(JNIEnv *env, jobject obj, jobject byteBuffer) { + (void)obj; + uint8_t *dst = (uint8_t *)(*env)->GetDirectBufferAddress(env, byteBuffer); + if (!dst) return; + pthread_mutex_lock(&g_lock); + memcpy(dst, g_framebuf, FRAME_W * FRAME_H * 4); + pthread_mutex_unlock(&g_lock); +} + +JNI_FN(void, injectKey)(JNIEnv *env, jobject obj, jint keyCode, jboolean pressed) { + (void)env; (void)obj; +#ifdef HAVE_VICE_SRC + int row = (keyCode >> 8) & 0xFF; + int col = keyCode & 0xFF; + keyboard_set_keyarr(row, col, pressed ? 1 : 0); +#else + (void)keyCode; (void)pressed; +#endif +} diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/layout/activity_main.xml b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/layout/activity_main.xml index 595b6d8..46e47de 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/layout/activity_main.xml +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/layout/activity_main.xml @@ -3,48 +3,73 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:padding="16dp"> + android:background="#111111" + android:padding="8dp"> - + + android:orientation="horizontal"> - + + + + + + + android:layout_height="0dp" + android:layout_weight="3" + android:background="#000044" /> + + + + + android:layout_marginTop="4dp" + android:text="Key events:" + android:textColor="#888888" + android:textSize="11sp" /> + android:background="#1A1A2E"> - + android:padding="6dp" + android:textColor="#00FF88" + android:textSize="11sp" /> diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/basic b/SchwertUndMagieOnPebbleCompanionApp/res/basic new file mode 100644 index 0000000..9e06923 Binary files /dev/null and b/SchwertUndMagieOnPebbleCompanionApp/res/basic differ diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/chargen b/SchwertUndMagieOnPebbleCompanionApp/res/chargen new file mode 100644 index 0000000..191ac46 Binary files /dev/null and b/SchwertUndMagieOnPebbleCompanionApp/res/chargen differ diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/kernal b/SchwertUndMagieOnPebbleCompanionApp/res/kernal new file mode 100644 index 0000000..666370a Binary files /dev/null and b/SchwertUndMagieOnPebbleCompanionApp/res/kernal differ diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/vice-3.8.tar.gz b/SchwertUndMagieOnPebbleCompanionApp/res/vice-3.8.tar.gz new file mode 100644 index 0000000..e353fd7 Binary files /dev/null and b/SchwertUndMagieOnPebbleCompanionApp/res/vice-3.8.tar.gz differ