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:
@@ -16,5 +16,9 @@ local.properties
|
|||||||
/SchwertUndMagieOnPebbleCompanionApp/.idea/assetWizardSettings.xml
|
/SchwertUndMagieOnPebbleCompanionApp/.idea/assetWizardSettings.xml
|
||||||
/SchwertUndMagieOnPebbleCompanionApp/build
|
/SchwertUndMagieOnPebbleCompanionApp/build
|
||||||
/SchwertUndMagieOnPebbleCompanionApp/captures
|
/SchwertUndMagieOnPebbleCompanionApp/captures
|
||||||
|
/SchwertUndMagieOnPebbleCompanionApp/app/build
|
||||||
|
/SchwertUndMagieOnPebbleCompanionApp/app/.cxx
|
||||||
|
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
|
||||||
|
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
|
||||||
|
|
||||||
/SchwertUndMagieOnPebbleWatchApp/build
|
/SchwertUndMagieOnPebbleWatchApp/build
|
||||||
|
|||||||
@@ -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 <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`.)
|
||||||
@@ -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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
}
|
}
|
||||||
@@ -10,6 +12,8 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ndkVersion = "30.0.14904198"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.ladkau.schwertundmagieonpebblecompanionapp"
|
applicationId = "de.ladkau.schwertundmagieonpebblecompanionapp"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
@@ -18,6 +22,17 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
buildTypes {
|
||||||
@@ -33,6 +48,86 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = 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 {
|
dependencies {
|
||||||
|
|||||||
+59
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-23
@@ -13,59 +13,112 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private const val PORT = 8888
|
private const val PORT = 8888
|
||||||
private const val TAG = "SuM"
|
private const val TAG = "SuM"
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var tvStatus: TextView
|
private lateinit var tvStatus: TextView
|
||||||
private lateinit var tvTime: TextView
|
private lateinit var tvTime: TextView
|
||||||
private lateinit var tvLog: TextView
|
private lateinit var tvLog: TextView
|
||||||
private lateinit var scrollLog: ScrollView
|
private lateinit var scrollLog: ScrollView
|
||||||
|
private lateinit var display: C64DisplayView
|
||||||
|
private lateinit var keyboard: C64KeyboardView
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
private var server: CompanionServer? = null
|
|
||||||
|
private var server: CompanionServer? = null
|
||||||
|
private val engine = C64Engine()
|
||||||
|
private var emuThread: Thread? = null
|
||||||
|
@Volatile private var emuRunning = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
tvStatus = findViewById(R.id.tv_status)
|
tvStatus = findViewById(R.id.tv_status)
|
||||||
tvTime = findViewById(R.id.tv_time)
|
tvTime = findViewById(R.id.tv_time)
|
||||||
tvLog = findViewById(R.id.tv_log)
|
tvLog = findViewById(R.id.tv_log)
|
||||||
scrollLog = findViewById(R.id.scroll_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,
|
server = CompanionServer(PORT, timeFormat,
|
||||||
onTimeFetched = { time ->
|
onTimeFetched = { time ->
|
||||||
mainHandler.post { tvTime.text = "Serving time: $time" }
|
mainHandler.post { tvTime.text = "Serving time: $time" }
|
||||||
},
|
},
|
||||||
onKey = { cmd ->
|
onKey = { cmd ->
|
||||||
Log.d(TAG, "Key received: $cmd")
|
Log.d(TAG, "Watch key: $cmd")
|
||||||
mainHandler.post { appendLog(cmd) }
|
mainHandler.post { appendLog("WATCH: $cmd") }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
server?.start()
|
server?.start()
|
||||||
tvStatus.text = "Listening on localhost:$PORT"
|
|
||||||
Log.d(TAG, "HTTP server started on port $PORT")
|
Log.d(TAG, "HTTP server started on port $PORT")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
tvStatus.text = "Server error: ${e.message}"
|
|
||||||
Log.e(TAG, "Failed to start server", e)
|
Log.e(TAG, "Failed to start server", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------
|
||||||
|
|
||||||
private fun appendLog(entry: String) {
|
private fun appendLog(entry: String) {
|
||||||
val timestamp = timeFormat.format(Date())
|
val ts = timeFormat.format(Date())
|
||||||
tvLog.text = "[$timestamp] $entry\n${tvLog.text}"
|
tvLog.text = "[$ts] $entry\n${tvLog.text}"
|
||||||
scrollLog.post { scrollLog.scrollTo(0, 0) }
|
scrollLog.post { scrollLog.scrollTo(0, 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
emuRunning = false
|
||||||
|
emuThread?.join(500)
|
||||||
server?.stop()
|
server?.stop()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class CompanionServer(
|
class CompanionServer(
|
||||||
port: Int,
|
port: Int,
|
||||||
private val timeFormat: SimpleDateFormat,
|
private val timeFormat: SimpleDateFormat,
|
||||||
@@ -74,15 +127,12 @@ class CompanionServer(
|
|||||||
) : NanoHTTPD(port) {
|
) : NanoHTTPD(port) {
|
||||||
|
|
||||||
override fun serve(session: IHTTPSession): Response {
|
override fun serve(session: IHTTPSession): Response {
|
||||||
Log.d(TAG, "HTTP ${session.method} ${session.uri} params=${session.parameters}")
|
|
||||||
val response = when (session.uri) {
|
val response = when (session.uri) {
|
||||||
"/time" -> {
|
"/time" -> {
|
||||||
val time = timeFormat.format(Date())
|
val time = timeFormat.format(Date())
|
||||||
onTimeFetched(time)
|
onTimeFetched(time)
|
||||||
newFixedLengthResponse(
|
newFixedLengthResponse(
|
||||||
Response.Status.OK,
|
Response.Status.OK, "application/json", """{"time":"$time"}"""
|
||||||
"application/json",
|
|
||||||
"""{"time":"$time"}"""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"/key" -> {
|
"/key" -> {
|
||||||
@@ -90,9 +140,7 @@ class CompanionServer(
|
|||||||
if (cmd.isNotEmpty()) onKey(cmd)
|
if (cmd.isNotEmpty()) onKey(cmd)
|
||||||
newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
|
newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
|
||||||
}
|
}
|
||||||
else -> newFixedLengthResponse(
|
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
|
||||||
Response.Status.NOT_FOUND, "text/plain", "not found"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
response.addHeader("Access-Control-Allow-Origin", "*")
|
response.addHeader("Access-Control-Allow-Origin", "*")
|
||||||
return response
|
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_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:background="#111111"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
<TextView
|
<!-- Status bar -->
|
||||||
android:id="@+id/tv_status"
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Watch: Not connected"
|
android:orientation="horizontal">
|
||||||
android:textSize="16sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_time"
|
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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="0dp"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_weight="3"
|
||||||
android:text="Sending: --:--:--"
|
android:background="#000044" />
|
||||||
android:textSize="24sp" />
|
|
||||||
|
|
||||||
|
<!-- 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
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="4dp"
|
||||||
android:text="Keystrokes from watch:"
|
android:text="Key events:"
|
||||||
android:textSize="14sp"
|
android:textColor="#888888"
|
||||||
android:textStyle="bold" />
|
android:textSize="11sp" />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
android:id="@+id/scroll_log"
|
android:id="@+id/scroll_log"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:background="#F5F5F5">
|
android:background="#1A1A2E">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_log"
|
android:id="@+id/tv_log"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:padding="8dp"
|
android:padding="6dp"
|
||||||
android:textSize="13sp" />
|
android:textColor="#00FF88"
|
||||||
|
android:textSize="11sp" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user