Add a drive LED vor virtual disk device

Cleanup code base
Adding a splash screen
This commit is contained in:
ml
2026-06-14 07:35:31 +02:00
parent 392bbeb996
commit 65597b8fce
9 changed files with 424 additions and 136 deletions
@@ -15,7 +15,8 @@
android:theme="@style/Theme.SchwertUndMagieOnPebbleCompanionApp">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -5,57 +5,63 @@ 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 android.view.View
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.
* Renders the VICE 320×200 ARGB framebuffer into a View.
*
* Frame data is written from the emulator thread via captureFrame(), then the
* view is invalidated so onDraw() runs on the main thread under Choreographer
* vsync. All canvas operations stay on the main thread, giving smooth rendering
* synchronized with the hardware display refresh rate.
*/
class C64DisplayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : SurfaceView(context, attrs), SurfaceHolder.Callback {
) : View(context, attrs) {
private val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888)
private val pixelBuf: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4)
/* Two ByteBuffers: the emulator thread writes to backBuf, then atomically
* swaps it to frontBuf. onDraw() reads frontBuf without holding the lock. */
private val buf0: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4)
private val buf1: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4)
@Volatile private var frontBuf: ByteBuffer = buf0
private var backBuf: ByteBuffer = buf1
private val srcRect = Rect(0, 0, 320, 200)
private var dstRect = Rect()
init {
holder.addCallback(this)
setBackgroundColor(android.graphics.Color.BLACK)
setLayerType(LAYER_TYPE_HARDWARE, null)
}
/** Called from the emulator thread after every runFrame(). */
fun updateFrame(engine: C64Engine) {
if (!holder.surface.isValid) return
pixelBuf.rewind()
engine.getVideoBuffer(pixelBuf)
pixelBuf.rewind()
bitmap.copyPixelsFromBuffer(pixelBuf)
val canvas: Canvas = try { holder.lockCanvas() ?: return } catch (_: Exception) { return }
try {
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
} finally {
holder.unlockCanvasAndPost(canvas)
}
/** Called from the emulator thread — copies one C64 frame into the back buffer. */
fun captureFrame(engine: C64Engine) {
backBuf.rewind()
engine.getVideoBuffer(backBuf)
/* Swap: make the freshly-written buffer visible to onDraw() */
val tmp = frontBuf
frontBuf = backBuf
backBuf = tmp
}
override fun surfaceCreated(h: SurfaceHolder) = recalcDst()
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, h2: Int) = recalcDst()
override fun surfaceDestroyed(h: SurfaceHolder) {}
override fun onDraw(canvas: Canvas) {
val buf = frontBuf
buf.rewind()
bitmap.copyPixelsFromBuffer(buf)
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
}
private fun recalcDst() {
val vw = width.toFloat()
val vh = height.toFloat()
val scale = minOf(vw / 320f, vh / 200f)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
if (w <= 0 || h <= 0) return
val scale = minOf(w.toFloat() / 320f, h.toFloat() / 200f)
val sw = (320 * scale).toInt()
val sh = (200 * scale).toInt()
val ox = ((vw - sw) / 2).toInt()
val oy = ((vh - sh) / 2).toInt()
val ox = ((w - sw) / 2).toInt()
val oy = ((h - sh) / 2).toInt()
dstRect = Rect(ox, oy, ox + sw, oy + sh)
}
}
@@ -19,9 +19,12 @@ 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. */
/** Reset the machine and autostart from this disk image (use for A-side episode disks). */
external fun loadDisk(path: String): Boolean
/** Hot-swap disk in drive 8 without resetting (use for B-side / hero disks). */
external fun attachDisk(path: String): Boolean
/** Run the emulator for one video frame (~20 000 CPU cycles). */
external fun runFrame()
@@ -40,6 +43,12 @@ class C64Engine {
/** Enable or disable audio output. Disabled by default. */
external fun setSoundEnabled(enabled: Boolean)
/** Returns true if drive 8's LED is currently on (disk read/write in progress). */
external fun getDriveLed(): Boolean
/** Monotonically increasing frame counter — incremented once per rendered C64 frame. */
external fun getFrameCount(): Int
companion object {
init { System.loadLibrary("vice_jni") }
@@ -63,14 +72,45 @@ class C64Engine {
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
// Letters
const val KEY_Q = (7 shl 8) or 6
const val KEY_W = (1 shl 8) or 1
const val KEY_E = (1 shl 8) or 6
const val KEY_R = (2 shl 8) or 1
const val KEY_T = (2 shl 8) or 6
const val KEY_Y = (3 shl 8) or 1
const val KEY_U = (3 shl 8) or 6
const val KEY_I = (4 shl 8) or 1
const val KEY_O = (4 shl 8) or 6
const val KEY_P = (5 shl 8) or 1
const val KEY_A = (1 shl 8) or 2
const val KEY_S = (1 shl 8) or 5
const val KEY_D = (2 shl 8) or 2
const val KEY_F = (2 shl 8) or 5
const val KEY_G = (3 shl 8) or 2
const val KEY_H = (3 shl 8) or 5
const val KEY_J = (4 shl 8) or 2
const val KEY_K = (4 shl 8) or 5
const val KEY_L = (5 shl 8) or 2
const val KEY_Z = (1 shl 8) or 4
const val KEY_X = (2 shl 8) or 7
const val KEY_C = (2 shl 8) or 4
const val KEY_V = (3 shl 8) or 7
const val KEY_B = (3 shl 8) or 4
const val KEY_N = (4 shl 8) or 7
const val KEY_M = (4 shl 8) or 4
// Control / special
const val KEY_RETURN = (0 shl 8) or 1
const val KEY_SPACE = (7 shl 8) or 4
const val KEY_DEL = (0 shl 8) or 0
const val KEY_HOME = (6 shl 8) or 3
const val KEY_LSHIFT = (1 shl 8) or 7
const val KEY_RSHIFT = (6 shl 8) or 4
const val KEY_CTRL = (7 shl 8) or 2
const val KEY_CBM = (7 shl 8) or 5
const val KEY_CUR_UD = (0 shl 8) or 7 // alone = down; SHIFT+this = up
const val KEY_CUR_LR = (0 shl 8) or 2 // alone = right; SHIFT+this = left
// Function
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
@@ -9,15 +9,21 @@ 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)
/**
* A key can send one or more C64 matrix codes simultaneously.
* The two-code variant is used for shifted cursor keys (e.g. ↑ = LSHIFT + CUR_UD).
*/
data class C64Key(
val label: String,
val codes: List<Int>,
val widthWeight: Float = 1f
) {
constructor(label: String, code: Int, widthWeight: Float = 1f)
: this(label, listOf(code), widthWeight)
}
/** 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
@@ -26,30 +32,63 @@ class C64KeyboardView @JvmOverloads constructor(
var onKeyEvent: KeyEventListener? = null
private val rows: List<List<C64Key>> = listOf(
// Row 1 — function keys + DEL / HOME / RUN·STOP
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)
C64Key("F1", C64Engine.KEY_F1),
C64Key("F3", C64Engine.KEY_F3),
C64Key("F5", C64Engine.KEY_F5),
C64Key("F7", C64Engine.KEY_F7),
C64Key("DEL", C64Engine.KEY_DEL),
C64Key("HOME", C64Engine.KEY_HOME),
C64Key("STOP", C64Engine.KEY_RUNSTOP, 1.5f),
),
// Row 2 — numbers
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)
C64Key("9", C64Engine.KEY_9), C64Key("0", C64Engine.KEY_0),
),
// Row 3 — Q … P
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)
C64Key("T", C64Engine.KEY_T), C64Key("Y", C64Engine.KEY_Y),
C64Key("U", C64Engine.KEY_U), C64Key("I", C64Engine.KEY_I),
C64Key("O", C64Engine.KEY_O), C64Key("P", C64Engine.KEY_P),
),
// Row 4 — A … L + RETURN
listOf(
C64Key("SPACE", C64Engine.KEY_SPACE, 6f)
)
C64Key("A", C64Engine.KEY_A), C64Key("S", C64Engine.KEY_S),
C64Key("D", C64Engine.KEY_D), C64Key("F", C64Engine.KEY_F),
C64Key("G", C64Engine.KEY_G), C64Key("H", C64Engine.KEY_H),
C64Key("J", C64Engine.KEY_J), C64Key("K", C64Engine.KEY_K),
C64Key("L", C64Engine.KEY_L),
C64Key("RET", C64Engine.KEY_RETURN, 1.5f),
),
// Row 5 — SHIFT + Z … M + SHIFT
listOf(
C64Key("SHF", C64Engine.KEY_LSHIFT, 1.5f),
C64Key("Z", C64Engine.KEY_Z), C64Key("X", C64Engine.KEY_X),
C64Key("C", C64Engine.KEY_C), C64Key("V", C64Engine.KEY_V),
C64Key("B", C64Engine.KEY_B), C64Key("N", C64Engine.KEY_N),
C64Key("M", C64Engine.KEY_M),
C64Key("SHF", C64Engine.KEY_RSHIFT, 1.5f),
),
// Row 6 — modifiers + SPACE + cursor keys
// ↑ and ← are composite: LSHIFT + the cursor key
listOf(
C64Key("CTRL", C64Engine.KEY_CTRL),
C64Key("C=", C64Engine.KEY_CBM),
C64Key("SPACE", C64Engine.KEY_SPACE, 3f),
C64Key("", listOf(C64Engine.KEY_LSHIFT, C64Engine.KEY_CUR_LR)),
C64Key("", C64Engine.KEY_CUR_LR),
C64Key("", listOf(C64Engine.KEY_LSHIFT, C64Engine.KEY_CUR_UD)),
C64Key("", C64Engine.KEY_CUR_UD),
),
)
// 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 {
@@ -63,7 +102,6 @@ class C64KeyboardView @JvmOverloads constructor(
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) {
@@ -73,13 +111,13 @@ class C64KeyboardView @JvmOverloads constructor(
private fun buildKeyRects(totalW: Float, totalH: Float) {
val rowH = totalH / rows.size
val pad = 4f
val pad = 3f
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 kw = key.widthWeight * unitW
val rect = RectF(x + pad, rowIdx * rowH + pad, x + kw - pad, (rowIdx + 1) * rowH - pad)
x += kw
rect to key
@@ -89,18 +127,27 @@ class C64KeyboardView @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) {
val held = heldKeys.values.toSet()
textPaint.textSize = height / (rows.size * 2.5f)
// Scale text so it fits roughly in half the row height
textPaint.textSize = (height / rows.size) * 0.38f
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)
canvas.drawRoundRect(rect, 5f, 5f, if (key in held) pressedPaint else keyPaint)
drawKeyLabel(canvas, rect, key.label)
}
}
}
private fun drawKeyLabel(canvas: Canvas, rect: RectF, label: String) {
val lines = label.split("\n")
val lineH = textPaint.textSize * 1.15f
val totalTextH = lineH * lines.size
val startY = rect.centerY() - totalTextH / 2f + textPaint.textSize
lines.forEachIndexed { i, line ->
canvas.drawText(line, rect.centerX(), startY + i * lineH, textPaint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val idx = event.actionIndex
val pid = event.getPointerId(idx)
@@ -8,7 +8,12 @@ import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.util.Log
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.Choreographer
import android.widget.Button
import android.widget.ImageView
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
@@ -39,8 +44,6 @@ private data class DiskSlot(
class MainActivity : AppCompatActivity() {
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
@@ -53,9 +56,11 @@ class MainActivity : AppCompatActivity() {
private var server: CompanionServer? = null
private val engine = C64Engine()
private var emuThread: Thread? = null
@Volatile private var emuRunning = false
private lateinit var choreographer: Choreographer
@Volatile private var renderActive = false
private var soundEnabled = false
private lateinit var driveLedDrawable: GradientDrawable
@Volatile private var driveLedState = false
// ---- disk slots ---------------------------------------------------------
@@ -74,6 +79,7 @@ class MainActivity : AppCompatActivity() {
)
private val episodeButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>()
private var activeSlot: DiskSlot? = null
// ---- Disk import (multi-select) -----------------------------------------
@@ -105,6 +111,12 @@ class MainActivity : AppCompatActivity() {
drawerLayout = findViewById(R.id.drawer_layout)
driveLedDrawable = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#2A2A2A"))
}
findViewById<android.view.View>(R.id.drive_led).background = driveLedDrawable
// On Android 15+ the app draws behind the status bar (edge-to-edge enforced).
// Shift the main content down by the actual status bar height so the
// hamburger button isn't hidden underneath it.
@@ -115,8 +127,13 @@ class MainActivity : AppCompatActivity() {
view.setPadding(pad8, bars.top + pad8, pad8, pad8)
insets
}
tvStatus = findViewById(R.id.tv_status)
tvTime = findViewById(R.id.tv_time)
val navDrawer = findViewById<android.view.View>(R.id.nav_drawer)
ViewCompat.setOnApplyWindowInsetsListener(navDrawer) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val pad12 = (12 * resources.displayMetrics.density).toInt()
view.setPadding(pad12, bars.top + pad12, pad12, pad12)
insets
}
tvLog = findViewById(R.id.tv_log)
scrollLog = findViewById(R.id.scroll_log)
display = findViewById(R.id.c64_display)
@@ -155,10 +172,12 @@ class MainActivity : AppCompatActivity() {
findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) }
}
choreographer = Choreographer.getInstance()
initEmulator()
refreshDiskButtons()
startHttpServer()
wireKeyboard()
dismissSplashDelayed()
}
// ---- emulator -----------------------------------------------------------
@@ -169,19 +188,28 @@ class MainActivity : AppCompatActivity() {
copyBundledRoms(assetDir)
val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir)
tvStatus.text = if (ok) "Emulator ready" else "Emulator init failed"
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
if (elapsed < 20) Thread.sleep(20 - elapsed)
/* Choreographer drives rendering: fires on every hardware vsync (60/90/120 Hz).
* captureFrame() reads the VICE framebuffer on this main-thread callback, then
* invalidate() causes onDraw() to blit it — all under vsync, no tearing. */
renderActive = true
choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!renderActive) return
display.captureFrame(engine)
display.invalidate()
val led = engine.getDriveLed()
if (led != driveLedState) {
driveLedState = led
driveLedDrawable.setColor(
if (led) Color.parseColor("#44DD66")
else Color.parseColor("#2A2A2A")
)
}
choreographer.postFrameCallback(this)
}
}.also { it.name = "emu-loop"; it.isDaemon = true; it.start() }
})
}
// ---- bundled ROM extraction ---------------------------------------------
@@ -207,7 +235,7 @@ class MainActivity : AppCompatActivity() {
private fun wireKeyboard() {
keyboard.onKeyEvent = { key, pressed ->
engine.injectKey(key.code, pressed)
for (code in key.codes) engine.injectKey(code, pressed)
if (pressed) {
Log.d(TAG, "Key pressed: ${key.label}")
mainHandler.post { appendLog("KEY: ${key.label}") }
@@ -219,9 +247,7 @@ class MainActivity : AppCompatActivity() {
private fun startHttpServer() {
server = CompanionServer(PORT, timeFormat,
onTimeFetched = { time ->
mainHandler.post { tvTime.text = "Serving time: $time" }
},
onTimeFetched = { _ -> },
onKey = { cmd ->
Log.d(TAG, "Watch key: $cmd")
mainHandler.post { appendLog("WATCH: $cmd") }
@@ -244,7 +270,12 @@ class MainActivity : AppCompatActivity() {
private fun refreshDiskButtons() {
for (slot in diskSlots) {
episodeButtons[slot]?.isEnabled = diskFile(slot) != null
val btn = episodeButtons[slot] ?: continue
btn.isEnabled = diskFile(slot) != null
btn.backgroundTintList = if (slot == activeSlot)
ColorStateList.valueOf(Color.parseColor("#1A4A1A"))
else
ColorStateList.valueOf(Color.TRANSPARENT)
}
}
@@ -260,9 +291,16 @@ class MainActivity : AppCompatActivity() {
appendLog("Disk not found: ${slot.fileName}")
return
}
val ok = engine.loadDisk(file.absolutePath)
if (ok && slot.part == 'A') loadedADisks.add(slot.episode)
appendLog(if (ok) "Loaded: ${slot.label}" else "Load failed: ${slot.fileName}")
// A-side episode disks restart the machine; B-side and hero disks are hot-swapped.
val resetMachine = slot.part == 'A'
val ok = if (resetMachine) engine.loadDisk(file.absolutePath)
else engine.attachDisk(file.absolutePath)
if (ok) {
if (slot.part == 'A') loadedADisks.add(slot.episode)
activeSlot = slot
refreshDiskButtons()
}
appendLog(if (ok) "Inserted: ${slot.label}" else "Insert failed: ${slot.fileName}")
if (ok) drawerLayout.closeDrawer(GravityCompat.START)
}
@@ -388,6 +426,17 @@ class MainActivity : AppCompatActivity() {
return data
}
// ---- splash screen ------------------------------------------------------
private fun dismissSplashDelayed() {
mainHandler.postDelayed({
val splash = findViewById<ImageView>(R.id.splash_screen) ?: return@postDelayed
splash.animate().alpha(0f).setDuration(700).withEndAction {
splash.visibility = android.view.View.GONE
}.start()
}, 1500)
}
// ---- helpers ------------------------------------------------------------
private fun appendLog(entry: String) {
@@ -404,8 +453,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onDestroy() {
emuRunning = false
emuThread?.join(500)
renderActive = false
server?.stop()
super.onDestroy()
}
@@ -28,7 +28,9 @@ if(HAVE_VICE)
-Wl,--wrap=maincpu_mainloop
-Wl,--wrap=init_main
-Wl,--wrap=machine_init
-Wl,--wrap=console_init)
-Wl,--wrap=console_init
-Wl,--wrap=ui_display_drive_led
-Wl,--wrap=serial_trap_receive)
target_include_directories(vice_jni PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}"
"${VICE_SRC}/src"
@@ -18,8 +18,8 @@
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <errno.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/stat.h>
#include <android/log.h>
@@ -36,11 +36,14 @@ static uint32_t g_framebuf[FRAME_W * FRAME_H];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_ready = 0;
/* Pending disk path — written by loadDisk (Android thread), consumed by
* video_canvas_refresh (VICE thread). autostart_disk must be called from
* the VICE thread; calling it cross-thread corrupts VICE internal state. */
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER;
/* Pending disk operations — written by Android thread, consumed by
* video_canvas_refresh on the VICE thread (both attach and autostart APIs
* must be called from the VICE thread to avoid corrupting internal state).
* g_pending_disk → autostart_disk (resets the machine, used for A-side disks)
* g_pending_attach → file_system_attach_disk (hot-swap, used for B-side / hero disks) */
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER;
static char g_pending_disk[512];
static char g_pending_attach[512];
/* =========================================================================
* VICE integration (compiled only when libvice.a is linked in)
@@ -61,6 +64,55 @@ extern int __real_console_init(void);
#include "palette.h"
#include "keyboard.h"
#include "autostart.h"
#include "attach.h"
/* Drive LED state.
* g_io_frames: bumped by __wrap_serial_trap_receive; keeps LED lit ~100 ms after
* each KERNAL-trap byte read (intra-game loads, directory reads, etc.).
* g_load_frames: set to 3000 (~60 s at 50 fps) when autostart_in_progress()
* transitions from true to false. Approximates the real 1541 loading time that
* would otherwise keep the LED on throughout the GDG intro on real hardware.
* Reset to 0 on hot-disk-swap so a new disk's autostart gets a fresh window.
* g_attach_frames: short grace period so the LED lights immediately on hot-swap. */
static volatile int g_drive_led = 0;
static volatile int g_io_frames = 0;
static volatile int g_load_frames = 0;
static volatile int g_attach_frames = 0;
/* Incremented by video_canvas_refresh at the end of each rendered frame.
* Read by getFrameCount() from the Kotlin display loop so it only wakes up
* and blits to the SurfaceView when VICE has produced a new frame. */
static volatile int g_frame_count = 0;
/* --wrap=ui_display_drive_led: keep this wrap to prevent a duplicate-symbol link
* error (lld rejects our plain redefinition of the headless-arch stub even when
* our object comes first). The virtual device's drive LED is unreliable for us
* (via2d reset sets led_status=1 at startup and no 1541 CPU ever clears it), so
* we do NOT use it for g_drive_led. Just forward to the original no-op stub. */
void __real_ui_display_drive_led(unsigned int drive_number, unsigned int drive_base,
unsigned int led_pwm1, unsigned int led_pwm2);
void __wrap_ui_display_drive_led(unsigned int drive_number, unsigned int drive_base,
unsigned int led_pwm1, unsigned int led_pwm2) {
__real_ui_display_drive_led(drive_number, drive_base, led_pwm1, led_pwm2);
}
/* --wrap=serial_trap_receive: fires once per byte that the C64 KERNAL reads from
* the virtual disk device. c64.c installs serial_trap_receive as a function
* pointer from a different TU than serial-trap.c where it is defined, so the
* --wrap redirect works. Reset g_io_frames to keep the drive LED lit while data
* is flowing; the countdown in video_canvas_refresh turns it off 5 frames (~100 ms)
* after the last byte arrives. */
int __real_serial_trap_receive(void);
int __wrap_serial_trap_receive(void) {
g_io_frames = 5;
return __real_serial_trap_receive();
}
/* Override VICE's archdep_program_name so the startup banner shows a friendly
* name instead of "app_process64" (the actual Android process binary).
* Defining both symbols here prevents the linker from pulling in the conflicting
* archive member from libvice.a. */
const char *archdep_program_name(void) { return "ViceForSchwertUndMagie"; }
void archdep_program_name_free(void) { /* string literal — nothing to free */ }
/* =========================================================================
* Android OpenSL ES sound driver — registered with VICE before main_program()
@@ -122,8 +174,6 @@ static void sl_bufq_callback(SLAndroidSimpleBufferQueueItf bq, void *ctx) {
static int android_sound_init(const char *param, int *speed,
int *fragsize, int *fragnr, int *channels) {
(void)param;
LOGI("android_sound_init: speed=%d frag=%d nr=%d ch=%d",
*speed, *fragsize, *fragnr, *channels);
if (*channels > 2) *channels = 2;
if (*channels < 1) *channels = 1;
@@ -168,7 +218,6 @@ static int android_sound_init(const char *param, int *speed,
(*g_sl_bq)->Enqueue(g_sl_bq, g_sl_bufs[i], AUDIO_BUF_SAMPS * sizeof(int16_t));
}
g_sl_next_buf = 0;
LOGI("android_sound_init: OpenSL ES ready");
return 0;
}
@@ -207,7 +256,6 @@ static void android_sound_close(void) {
(*g_sl_engine_obj)->Destroy(g_sl_engine_obj);
g_sl_engine_obj = NULL; g_sl_engine = NULL;
}
LOGI("android_sound_close: done");
}
static const sound_device_t g_android_sound_device = {
@@ -226,7 +274,7 @@ static const sound_device_t g_android_sound_device = {
};
/* Stubs for symbols that live in arch-specific or excluded files. */
void main_exit(void) { LOGI("main_exit called — ignoring in JNI context"); }
void main_exit(void) {}
/* All functions below replace arch/headless/video.c (excluded from libvice.a). */
@@ -277,13 +325,22 @@ int video_canvas_set_palette(video_canvas_t *canvas, struct palette_s *palette)
return 0;
}
/* Full PAL raster dimensions — VICE allocates draw_buffer_line_size = *width
* bytes per row in the internal draw buffer. If we return 320 here, reading
* from xs=136 (PAL left edge of the visible area) wraps to the next row and
* scrambles the display. Returning the full PAL frame size (504×312) gives
* VICE enough room; video_canvas_render() writes into our 320×200 g_framebuf
* safely because the destination pitch is supplied separately as FRAME_W*4. */
#define PAL_W 504
#define PAL_H 312
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;
*width = PAL_W;
*height = PAL_H;
return canvas;
}
@@ -295,23 +352,45 @@ 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) {
/* Process any pending disk load on the VICE thread so autostart_disk is
* never called cross-thread (it modifies complex internal VICE state). */
/* Process any pending disk operations on the VICE thread. */
pthread_mutex_lock(&g_pending_lock);
if (g_pending_disk[0] != '\0') {
char path[512];
strncpy(path, g_pending_disk, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
g_pending_disk[0] = '\0';
pthread_mutex_unlock(&g_pending_lock);
LOGI("autostart_disk: %s", path);
int asd_ret = autostart_disk(8, 0, path, NULL, 0, AUTOSTART_MODE_RUN);
LOGI("autostart_disk returned %d", asd_ret);
} else {
pthread_mutex_unlock(&g_pending_lock);
char autostart_path[512] = {0};
char attach_path[512] = {0};
if (g_pending_disk[0] != '\0') { strncpy(autostart_path, g_pending_disk, sizeof(autostart_path)-1); g_pending_disk[0] = '\0'; }
if (g_pending_attach[0] != '\0') { strncpy(attach_path, g_pending_attach, sizeof(attach_path)-1); g_pending_attach[0] = '\0'; }
pthread_mutex_unlock(&g_pending_lock);
if (autostart_path[0] != '\0')
autostart_disk(8, 0, autostart_path, NULL, 0, AUTOSTART_MODE_RUN);
if (attach_path[0] != '\0') {
int r = file_system_attach_disk(8, 0, attach_path);
if (r == 0) {
g_load_frames = 0; /* clear old cooldown; new autostart will set a fresh one */
g_attach_frames = 75; /* show LED for ~1.5 s immediately after hot-swap */
}
}
/* Clamp destination to our framebuffer bounds. */
/* Drive LED.
* VICE's virtual device (vdrive) loads far faster than a real 1541, so
* autostart_in_progress() is false long before the GDG intro finishes.
* When autostart ends, start a ~60-second cooldown (g_load_frames) that
* keeps the LED on throughout the typical intro duration, approximating
* the real 1541 loading experience. */
static int prev_autostart = -1;
int cur_autostart = autostart_in_progress();
if (prev_autostart == 1 && cur_autostart == 0)
g_load_frames = 3000; /* ~60 s at 50 fps */
prev_autostart = cur_autostart;
if (g_io_frames > 0) g_io_frames--;
if (g_load_frames > 0) g_load_frames--;
if (g_attach_frames > 0) g_attach_frames--;
g_drive_led = (cur_autostart || g_io_frames > 0 || g_load_frames > 0 || g_attach_frames > 0) ? 1 : 0;
/* Clamp source region so it stays within the PAL draw buffer. */
if (xs >= PAL_W || ys >= PAL_H) return;
if (xs + w > PAL_W) w = PAL_W - xs;
if (ys + h > PAL_H) h = PAL_H - ys;
/* Clamp destination to our 320×200 framebuffer. */
if (xi >= FRAME_W || yi >= FRAME_H) return;
if (xi + w > FRAME_W) w = FRAME_W - xi;
if (yi + h > FRAME_H) h = FRAME_H - yi;
@@ -324,6 +403,21 @@ void video_canvas_refresh(video_canvas_t *canvas,
(int)xi, (int)yi,
FRAME_W * 4);
pthread_mutex_unlock(&g_lock);
/* Increment g_frame_count once per C64 video frame. VICE may call
* video_canvas_refresh multiple times per logical frame (dirty-rect
* updates), so guard with a 15 ms minimum interval (~75 % of a 20 ms
* frame period at 50 fps) — the first call per frame triggers the counter;
* subsequent calls within the same frame window are skipped. */
static struct timespec g_last_frame_ts;
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
int64_t now_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
int64_t last_ns = (int64_t)g_last_frame_ts.tv_sec * 1000000000LL + g_last_frame_ts.tv_nsec;
if (now_ns - last_ns >= 15000000LL) {
g_last_frame_ts = ts;
g_frame_count++;
}
}
/* VICE main loop runs in this thread. */
@@ -353,9 +447,7 @@ static void *stderr_reader_thread(void *arg) {
static void *vice_thread(void *arg) {
(void)arg;
LOGI("vice_thread: starting main_program");
int rc = main_program(g_vice_argc, g_vice_argv);
LOGI("vice_thread: main_program returned %d", rc);
main_program(g_vice_argc, g_vice_argv);
return NULL;
}
@@ -363,7 +455,7 @@ static void *vice_thread(void *arg) {
* --wrap=archdep_vice_exit redirects all calls here so only the VICE thread
* terminates instead. */
void __wrap_archdep_vice_exit(int code) {
LOGI("archdep_vice_exit(%d) — intercepted, terminating VICE thread", code);
(void)code;
pthread_exit(NULL);
}
@@ -372,6 +464,8 @@ void __wrap_maincpu_mainloop(void) { __real_maincpu_mainloop(); }
int __wrap_init_main(void) { return __real_init_main(); }
int __wrap_machine_init(void) { return __real_machine_init(); }
int __wrap_console_init(void) { return __real_console_init(); }
#endif /* HAVE_VICE_SRC */
/* =========================================================================
@@ -384,7 +478,6 @@ int __wrap_console_init(void) { return __real_console_init(); }
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];
@@ -412,13 +505,12 @@ JNI_FN(jboolean, initEmulator)(JNIEnv *env, jobject obj, jstring romDir) {
snprintf(src, sizeof(src), "%s/%s", dir_copy, c64_roms[i]);
snprintf(dst, sizeof(dst), "%s/%s", vice_c64_dir, c64_roms[i]);
FILE *fsrc = fopen(src, "rb");
if (!fsrc) { LOGI("ROM not found: %s", src); continue; }
if (!fsrc) continue;
FILE *fdst = fopen(dst, "wb");
if (!fdst) { fclose(fsrc); LOGI("ROM copy failed: %s", dst); continue; }
if (!fdst) { fclose(fsrc); continue; }
char buf[4096]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
fclose(fsrc); fclose(fdst);
LOGI("ROM copied: %s", c64_roms[i]);
}
/* Copy 1541 drive ROM into romDir/DRIVES/1541 (sysfile_load path for drives).
@@ -438,11 +530,8 @@ JNI_FN(jboolean, initEmulator)(JNIEnv *env, jobject obj, jstring romDir) {
char buf[4096]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
fclose(fdst);
LOGI("ROM copied: 1541");
}
fclose(fsrc);
} else {
LOGI("1541 ROM not found — drive may not work with fast loaders");
}
}
@@ -544,6 +633,36 @@ JNI_FN(jboolean, loadDisk)(JNIEnv *env, jobject obj, jstring path) {
#endif
}
JNI_FN(jboolean, attachDisk)(JNIEnv *env, jobject obj, jstring path) {
(void)obj;
#ifdef HAVE_VICE_SRC
const char *p = (*env)->GetStringUTFChars(env, path, NULL);
pthread_mutex_lock(&g_pending_lock);
strncpy(g_pending_attach, p, sizeof(g_pending_attach) - 1);
g_pending_attach[sizeof(g_pending_attach) - 1] = '\0';
pthread_mutex_unlock(&g_pending_lock);
(*env)->ReleaseStringUTFChars(env, path, p);
return JNI_TRUE;
#else
(void)env; (void)path;
return JNI_FALSE;
#endif
}
JNI_FN(jboolean, getDriveLed)(JNIEnv *env, jobject obj) {
(void)env; (void)obj;
#ifdef HAVE_VICE_SRC
return g_drive_led ? JNI_TRUE : JNI_FALSE;
#else
return JNI_FALSE;
#endif
}
JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) {
(void)env; (void)obj;
return (jint)g_frame_count;
}
/* 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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -32,22 +36,20 @@
android:textSize="16sp" />
<TextView
android:id="@+id/tv_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Initialising…"
android:textColor="#AAFFAA"
android:textSize="12sp" />
android:text="Schwert und Magie"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--:--:--"
android:textColor="#AAAAFF"
android:textSize="12sp" />
<View
android:id="@+id/drive_led"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginEnd="4dp" />
</LinearLayout>
<!-- C64 display (320×200, scaled with correct aspect ratio) -->
@@ -55,15 +57,15 @@
android:id="@+id/c64_display"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3"
android:layout_weight="2"
android:layout_marginTop="4dp" />
<!-- Virtual keyboard -->
<!-- Virtual keyboard — 6 rows -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64KeyboardView
android:id="@+id/c64_keyboard"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_weight="1.8"
android:layout_marginTop="4dp" />
<!-- Key-event log -->
@@ -96,6 +98,7 @@
<!-- ── Drawer ────────────────────────────────────────────────────────── -->
<LinearLayout
android:id="@+id/nav_drawer"
android:layout_width="280dp"
android:layout_height="match_parent"
android:layout_gravity="start"
@@ -110,7 +113,16 @@
android:textColor="#AAFFAA"
android:textSize="14sp"
android:textStyle="bold"
android:paddingBottom="8dp" />
android:paddingBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Disketten"
android:textColor="#AAFFAA"
android:textSize="12sp"
android:textStyle="bold"
android:paddingBottom="4dp" />
<Button android:id="@+id/btn_ep_1a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -234,3 +246,16 @@
</LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout>
<!-- Splash screen overlay — covers everything until VICE is ready -->
<ImageView
android:id="@+id/splash_screen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/splash_screen"
android:scaleType="fitCenter"
android:background="#000000"
android:clickable="true"
android:focusable="true" />
</FrameLayout>