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:
+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
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user