Add VICE C64 emulator integration to Android companion app

Cross-compiles VICE 3.8 headless for ARM64 and x86_64 as part of the
  Android Studio build (Gradle buildVice task + build_vice.sh). The JNI
  bridge (vice_jni.c) renders frames into a shared framebuffer read by
  C64DisplayView, and forwards key events from C64KeyboardView to VICE's
  keyboard matrix. Without ROM files the app shows a placeholder blue
  screen; with ROMs and a .d64 disk image the full C64 emulator runs.
This commit is contained in:
ml
2026-06-04 16:24:14 +02:00
parent 724157aca3
commit 8c7f2e3306
16 changed files with 1022 additions and 44 deletions
+4
View File
@@ -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
+134
View File
@@ -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, 19891992) 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 <phone-ip> # sideload via Pebble/Core app on the phone
pebble logs --phone <phone-ip> # 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/<abi>/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`.)
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
@@ -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/<abi>/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/<abi>/libvice.a already exists.
// The tarball at <project>/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 {
@@ -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)
}
}
@@ -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
}
}
@@ -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<List<C64Key>> = 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<Int, C64Key>()
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<List<Pair<RectF, C64Key>>> = 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
}
}
@@ -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
@@ -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()
@@ -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 ==="
@@ -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/<abi>/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 <jni.h>
#include <stdint.h>
#include <string.h>
#include <pthread.h>
#include <android/log.h>
#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 <romDir> -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
}
@@ -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">
<TextView
android:id="@+id/tv_status"
<!-- Status bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Watch: Not connected"
android:textSize="16sp"
android:textStyle="bold" />
android:orientation="horizontal">
<TextView
android:id="@+id/tv_time"
<TextView
android:id="@+id/tv_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Initialising…"
android:textColor="#AAFFAA"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--:--:--"
android:textColor="#AAAAFF"
android:textSize="12sp" />
</LinearLayout>
<!-- C64 display (320×200, scales to fill width, capped at 40 % of screen height) -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64DisplayView
android:id="@+id/c64_display"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Sending: --:--:--"
android:textSize="24sp" />
android:layout_height="0dp"
android:layout_weight="3"
android:background="#000044" />
<!-- Virtual keyboard -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64KeyboardView
android:id="@+id/c64_keyboard"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_marginTop="4dp" />
<!-- Key-event log -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Keystrokes from watch:"
android:textSize="14sp"
android:textStyle="bold" />
android:layout_marginTop="4dp"
android:text="Key events:"
android:textColor="#888888"
android:textSize="11sp" />
<ScrollView
android:id="@+id/scroll_log"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:layout_weight="1"
android:background="#F5F5F5">
android:background="#1A1A2E">
<TextView
android:id="@+id/tv_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:padding="8dp"
android:textSize="13sp" />
android:padding="6dp"
android:textColor="#00FF88"
android:textSize="11sp" />
</ScrollView>
</LinearLayout>
Binary file not shown.
Binary file not shown.
Binary file not shown.