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"> android:theme="@style/Theme.SchwertUndMagieOnPebbleCompanionApp">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -5,57 +5,63 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.SurfaceHolder import android.view.View
import android.view.SurfaceView
import java.nio.ByteBuffer import java.nio.ByteBuffer
/** /**
* Renders the VICE 320×200 ARGB framebuffer into a SurfaceView, * Renders the VICE 320×200 ARGB framebuffer into a View.
* scaled to fill the view while keeping the 8:5 aspect ratio. *
* 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( class C64DisplayView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null attrs: AttributeSet? = null
) : SurfaceView(context, attrs), SurfaceHolder.Callback { ) : View(context, attrs) {
private val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888) 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 val srcRect = Rect(0, 0, 320, 200)
private var dstRect = Rect() private var dstRect = Rect()
init { init {
holder.addCallback(this) setBackgroundColor(android.graphics.Color.BLACK)
setLayerType(LAYER_TYPE_HARDWARE, null)
} }
/** Called from the emulator thread after every runFrame(). */ /** Called from the emulator thread — copies one C64 frame into the back buffer. */
fun updateFrame(engine: C64Engine) { fun captureFrame(engine: C64Engine) {
if (!holder.surface.isValid) return backBuf.rewind()
engine.getVideoBuffer(backBuf)
pixelBuf.rewind() /* Swap: make the freshly-written buffer visible to onDraw() */
engine.getVideoBuffer(pixelBuf) val tmp = frontBuf
pixelBuf.rewind() frontBuf = backBuf
bitmap.copyPixelsFromBuffer(pixelBuf) backBuf = tmp
val canvas: Canvas = try { holder.lockCanvas() ?: return } catch (_: Exception) { return }
try {
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
} finally {
holder.unlockCanvasAndPost(canvas)
}
} }
override fun surfaceCreated(h: SurfaceHolder) = recalcDst() override fun onDraw(canvas: Canvas) {
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, h2: Int) = recalcDst() val buf = frontBuf
override fun surfaceDestroyed(h: SurfaceHolder) {} buf.rewind()
bitmap.copyPixelsFromBuffer(buf)
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
}
private fun recalcDst() { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val vw = width.toFloat() if (w <= 0 || h <= 0) return
val vh = height.toFloat() val scale = minOf(w.toFloat() / 320f, h.toFloat() / 200f)
val scale = minOf(vw / 320f, vh / 200f)
val sw = (320 * scale).toInt() val sw = (320 * scale).toInt()
val sh = (200 * scale).toInt() val sh = (200 * scale).toInt()
val ox = ((vw - sw) / 2).toInt() val ox = ((w - sw) / 2).toInt()
val oy = ((vh - sh) / 2).toInt() val oy = ((h - sh) / 2).toInt()
dstRect = Rect(ox, oy, ox + sw, oy + sh) dstRect = Rect(ox, oy, ox + sw, oy + sh)
} }
} }
@@ -19,9 +19,12 @@ class C64Engine {
/** Initialize VICE. romDir should contain kernal, basic, chargen. */ /** Initialize VICE. romDir should contain kernal, basic, chargen. */
external fun initEmulator(romDir: String): Boolean 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 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). */ /** Run the emulator for one video frame (~20 000 CPU cycles). */
external fun runFrame() external fun runFrame()
@@ -40,6 +43,12 @@ class C64Engine {
/** Enable or disable audio output. Disabled by default. */ /** Enable or disable audio output. Disabled by default. */
external fun setSoundEnabled(enabled: Boolean) 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 { companion object {
init { System.loadLibrary("vice_jni") } init { System.loadLibrary("vice_jni") }
@@ -63,14 +72,45 @@ class C64Engine {
const val KEY_8 = (3 shl 8) or 3 const val KEY_8 = (3 shl 8) or 3
const val KEY_9 = (4 shl 8) or 0 const val KEY_9 = (4 shl 8) or 0
const val KEY_0 = (4 shl 8) or 3 const val KEY_0 = (4 shl 8) or 3
// Letters
const val KEY_Q = (7 shl 8) or 6 const val KEY_Q = (7 shl 8) or 6
const val KEY_W = (1 shl 8) or 1 const val KEY_W = (1 shl 8) or 1
const val KEY_E = (1 shl 8) or 6 const val KEY_E = (1 shl 8) or 6
const val KEY_R = (2 shl 8) or 1 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_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_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_RETURN = (0 shl 8) or 1
const val KEY_SPACE = (7 shl 8) or 4 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_F1 = (0 shl 8) or 4
const val KEY_F3 = (0 shl 8) or 5 const val KEY_F3 = (0 shl 8) or 5
const val KEY_F5 = (0 shl 8) or 6 const val KEY_F5 = (0 shl 8) or 6
@@ -9,15 +9,21 @@ import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View 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 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( class C64KeyboardView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null attrs: AttributeSet? = null
@@ -26,30 +32,63 @@ class C64KeyboardView @JvmOverloads constructor(
var onKeyEvent: KeyEventListener? = null var onKeyEvent: KeyEventListener? = null
private val rows: List<List<C64Key>> = listOf( private val rows: List<List<C64Key>> = listOf(
// Row 1 — function keys + DEL / HOME / RUN·STOP
listOf( listOf(
C64Key("F1", C64Engine.KEY_F1), C64Key("F3", C64Engine.KEY_F3), C64Key("F1", C64Engine.KEY_F1),
C64Key("F5", C64Engine.KEY_F5), C64Key("F7", C64Engine.KEY_F7), C64Key("F3", C64Engine.KEY_F3),
C64Key("RUN\nSTOP", C64Engine.KEY_RUNSTOP, 1.5f) 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( listOf(
C64Key("1", C64Engine.KEY_1), C64Key("2", C64Engine.KEY_2), C64Key("1", C64Engine.KEY_1), C64Key("2", C64Engine.KEY_2),
C64Key("3", C64Engine.KEY_3), C64Key("4", C64Engine.KEY_4), C64Key("3", C64Engine.KEY_3), C64Key("4", C64Engine.KEY_4),
C64Key("5", C64Engine.KEY_5), C64Key("6", C64Engine.KEY_6), C64Key("5", C64Engine.KEY_5), C64Key("6", C64Engine.KEY_6),
C64Key("7", C64Engine.KEY_7), C64Key("8", C64Engine.KEY_8), 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( listOf(
C64Key("Q", C64Engine.KEY_Q), C64Key("W", C64Engine.KEY_W), C64Key("Q", C64Engine.KEY_Q), C64Key("W", C64Engine.KEY_W),
C64Key("E", C64Engine.KEY_E), C64Key("R", C64Engine.KEY_R), C64Key("E", C64Engine.KEY_E), C64Key("R", C64Engine.KEY_R),
C64Key("Y", C64Engine.KEY_Y), C64Key("N", C64Engine.KEY_N), C64Key("T", C64Engine.KEY_T), C64Key("Y", C64Engine.KEY_Y),
C64Key("RETURN", C64Engine.KEY_RETURN, 2f) 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( 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 heldKeys = mutableMapOf<Int, C64Key>()
private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -63,7 +102,6 @@ class C64KeyboardView @JvmOverloads constructor(
textAlign = Paint.Align.CENTER 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() private var keyRects: List<List<Pair<RectF, C64Key>>> = emptyList()
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { 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) { private fun buildKeyRects(totalW: Float, totalH: Float) {
val rowH = totalH / rows.size val rowH = totalH / rows.size
val pad = 4f val pad = 3f
keyRects = rows.mapIndexed { rowIdx, row -> keyRects = rows.mapIndexed { rowIdx, row ->
val totalUnits = row.sumOf { it.widthWeight.toDouble() }.toFloat() val totalUnits = row.sumOf { it.widthWeight.toDouble() }.toFloat()
val unitW = totalW / totalUnits val unitW = totalW / totalUnits
var x = 0f var x = 0f
row.map { key -> 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) val rect = RectF(x + pad, rowIdx * rowH + pad, x + kw - pad, (rowIdx + 1) * rowH - pad)
x += kw x += kw
rect to key rect to key
@@ -89,18 +127,27 @@ class C64KeyboardView @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
val held = heldKeys.values.toSet() 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 (row in keyRects) {
for ((rect, key) in row) { for ((rect, key) in row) {
val paint = if (key in held) pressedPaint else keyPaint canvas.drawRoundRect(rect, 5f, 5f, if (key in held) pressedPaint else keyPaint)
canvas.drawRoundRect(rect, 6f, 6f, paint) drawKeyLabel(canvas, rect, key.label)
val ty = rect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2
canvas.drawText(key.label, rect.centerX(), ty, textPaint)
} }
} }
} }
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 { override fun onTouchEvent(event: MotionEvent): Boolean {
val idx = event.actionIndex val idx = event.actionIndex
val pid = event.getPointerId(idx) val pid = event.getPointerId(idx)
@@ -8,7 +8,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log 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.Button
import android.widget.ImageView
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@@ -39,8 +44,6 @@ private data class DiskSlot(
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var tvStatus: 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 display: C64DisplayView
@@ -53,9 +56,11 @@ class MainActivity : AppCompatActivity() {
private var server: CompanionServer? = null private var server: CompanionServer? = null
private val engine = C64Engine() private val engine = C64Engine()
private var emuThread: Thread? = null private lateinit var choreographer: Choreographer
@Volatile private var emuRunning = false @Volatile private var renderActive = false
private var soundEnabled = false private var soundEnabled = false
private lateinit var driveLedDrawable: GradientDrawable
@Volatile private var driveLedState = false
// ---- disk slots --------------------------------------------------------- // ---- disk slots ---------------------------------------------------------
@@ -74,6 +79,7 @@ class MainActivity : AppCompatActivity() {
) )
private val episodeButtons = mutableMapOf<DiskSlot, Button>() private val episodeButtons = mutableMapOf<DiskSlot, Button>()
private val loadedADisks = mutableSetOf<Int>() private val loadedADisks = mutableSetOf<Int>()
private var activeSlot: DiskSlot? = null
// ---- Disk import (multi-select) ----------------------------------------- // ---- Disk import (multi-select) -----------------------------------------
@@ -105,6 +111,12 @@ class MainActivity : AppCompatActivity() {
drawerLayout = findViewById(R.id.drawer_layout) 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). // 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 // Shift the main content down by the actual status bar height so the
// hamburger button isn't hidden underneath it. // hamburger button isn't hidden underneath it.
@@ -115,8 +127,13 @@ class MainActivity : AppCompatActivity() {
view.setPadding(pad8, bars.top + pad8, pad8, pad8) view.setPadding(pad8, bars.top + pad8, pad8, pad8)
insets insets
} }
tvStatus = findViewById(R.id.tv_status) val navDrawer = findViewById<android.view.View>(R.id.nav_drawer)
tvTime = findViewById(R.id.tv_time) 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) tvLog = findViewById(R.id.tv_log)
scrollLog = findViewById(R.id.scroll_log) scrollLog = findViewById(R.id.scroll_log)
display = findViewById(R.id.c64_display) display = findViewById(R.id.c64_display)
@@ -155,10 +172,12 @@ class MainActivity : AppCompatActivity() {
findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) } findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) }
} }
choreographer = Choreographer.getInstance()
initEmulator() initEmulator()
refreshDiskButtons() refreshDiskButtons()
startHttpServer() startHttpServer()
wireKeyboard() wireKeyboard()
dismissSplashDelayed()
} }
// ---- emulator ----------------------------------------------------------- // ---- emulator -----------------------------------------------------------
@@ -169,19 +188,28 @@ class MainActivity : AppCompatActivity() {
copyBundledRoms(assetDir) copyBundledRoms(assetDir)
val romDir = assetDir.absolutePath val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir) val ok = engine.initEmulator(romDir)
tvStatus.text = if (ok) "Emulator ready" else "Emulator init failed"
Log.d(TAG, "initEmulator=$ok romDir=$romDir") Log.d(TAG, "initEmulator=$ok romDir=$romDir")
emuRunning = true /* Choreographer drives rendering: fires on every hardware vsync (60/90/120 Hz).
emuThread = Thread { * captureFrame() reads the VICE framebuffer on this main-thread callback, then
while (emuRunning) { * invalidate() causes onDraw() to blit it — all under vsync, no tearing. */
val t0 = System.currentTimeMillis() renderActive = true
engine.runFrame() choreographer.postFrameCallback(object : Choreographer.FrameCallback {
display.updateFrame(engine) override fun doFrame(frameTimeNanos: Long) {
val elapsed = System.currentTimeMillis() - t0 if (!renderActive) return
if (elapsed < 20) Thread.sleep(20 - elapsed) 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 --------------------------------------------- // ---- bundled ROM extraction ---------------------------------------------
@@ -207,7 +235,7 @@ class MainActivity : AppCompatActivity() {
private fun wireKeyboard() { private fun wireKeyboard() {
keyboard.onKeyEvent = { key, pressed -> keyboard.onKeyEvent = { key, pressed ->
engine.injectKey(key.code, pressed) for (code in key.codes) engine.injectKey(code, pressed)
if (pressed) { if (pressed) {
Log.d(TAG, "Key pressed: ${key.label}") Log.d(TAG, "Key pressed: ${key.label}")
mainHandler.post { appendLog("KEY: ${key.label}") } mainHandler.post { appendLog("KEY: ${key.label}") }
@@ -219,9 +247,7 @@ class MainActivity : AppCompatActivity() {
private fun startHttpServer() { private fun startHttpServer() {
server = CompanionServer(PORT, timeFormat, server = CompanionServer(PORT, timeFormat,
onTimeFetched = { time -> onTimeFetched = { _ -> },
mainHandler.post { tvTime.text = "Serving time: $time" }
},
onKey = { cmd -> onKey = { cmd ->
Log.d(TAG, "Watch key: $cmd") Log.d(TAG, "Watch key: $cmd")
mainHandler.post { appendLog("WATCH: $cmd") } mainHandler.post { appendLog("WATCH: $cmd") }
@@ -244,7 +270,12 @@ class MainActivity : AppCompatActivity() {
private fun refreshDiskButtons() { private fun refreshDiskButtons() {
for (slot in diskSlots) { 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}") appendLog("Disk not found: ${slot.fileName}")
return return
} }
val ok = engine.loadDisk(file.absolutePath) // A-side episode disks restart the machine; B-side and hero disks are hot-swapped.
if (ok && slot.part == 'A') loadedADisks.add(slot.episode) val resetMachine = slot.part == 'A'
appendLog(if (ok) "Loaded: ${slot.label}" else "Load failed: ${slot.fileName}") 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) if (ok) drawerLayout.closeDrawer(GravityCompat.START)
} }
@@ -388,6 +426,17 @@ class MainActivity : AppCompatActivity() {
return data 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 ------------------------------------------------------------ // ---- helpers ------------------------------------------------------------
private fun appendLog(entry: String) { private fun appendLog(entry: String) {
@@ -404,8 +453,7 @@ class MainActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
emuRunning = false renderActive = false
emuThread?.join(500)
server?.stop() server?.stop()
super.onDestroy() super.onDestroy()
} }
@@ -28,7 +28,9 @@ if(HAVE_VICE)
-Wl,--wrap=maincpu_mainloop -Wl,--wrap=maincpu_mainloop
-Wl,--wrap=init_main -Wl,--wrap=init_main
-Wl,--wrap=machine_init -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 target_include_directories(vice_jni PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}" "${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}"
"${VICE_SRC}/src" "${VICE_SRC}/src"
@@ -18,8 +18,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <pthread.h> #include <pthread.h>
#include <errno.h>
#include <stdio.h> #include <stdio.h>
#include <time.h>
#include <unistd.h> #include <unistd.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <android/log.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 pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_ready = 0; static int g_ready = 0;
/* Pending disk path — written by loadDisk (Android thread), consumed by /* Pending disk operations — written by Android thread, consumed by
* video_canvas_refresh (VICE thread). autostart_disk must be called from * video_canvas_refresh on the VICE thread (both attach and autostart APIs
* the VICE thread; calling it cross-thread corrupts VICE internal state. */ * must be called from the VICE thread to avoid corrupting internal state).
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER; * 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_disk[512];
static char g_pending_attach[512];
/* ========================================================================= /* =========================================================================
* VICE integration (compiled only when libvice.a is linked in) * VICE integration (compiled only when libvice.a is linked in)
@@ -61,6 +64,55 @@ extern int __real_console_init(void);
#include "palette.h" #include "palette.h"
#include "keyboard.h" #include "keyboard.h"
#include "autostart.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() * 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, static int android_sound_init(const char *param, int *speed,
int *fragsize, int *fragnr, int *channels) { int *fragsize, int *fragnr, int *channels) {
(void)param; (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 > 2) *channels = 2;
if (*channels < 1) *channels = 1; 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_bq)->Enqueue(g_sl_bq, g_sl_bufs[i], AUDIO_BUF_SAMPS * sizeof(int16_t));
} }
g_sl_next_buf = 0; g_sl_next_buf = 0;
LOGI("android_sound_init: OpenSL ES ready");
return 0; 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)->Destroy(g_sl_engine_obj);
g_sl_engine_obj = NULL; g_sl_engine = NULL; g_sl_engine_obj = NULL; g_sl_engine = NULL;
} }
LOGI("android_sound_close: done");
} }
static const sound_device_t g_android_sound_device = { 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. */ /* 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). */ /* 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; 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, video_canvas_t *video_canvas_create(video_canvas_t *canvas,
unsigned int *width, unsigned int *height, unsigned int *width, unsigned int *height,
int mapped) { int mapped) {
(void)mapped; (void)mapped;
canvas->created = 1; canvas->created = 1;
*width = FRAME_W; *width = PAL_W;
*height = FRAME_H; *height = PAL_H;
return canvas; return canvas;
} }
@@ -295,23 +352,45 @@ void video_canvas_refresh(video_canvas_t *canvas,
unsigned int xs, unsigned int ys, unsigned int xs, unsigned int ys,
unsigned int xi, unsigned int yi, unsigned int xi, unsigned int yi,
unsigned int w, unsigned int h) { unsigned int w, unsigned int h) {
/* Process any pending disk load on the VICE thread so autostart_disk is /* Process any pending disk operations on the VICE thread. */
* never called cross-thread (it modifies complex internal VICE state). */
pthread_mutex_lock(&g_pending_lock); pthread_mutex_lock(&g_pending_lock);
if (g_pending_disk[0] != '\0') { char autostart_path[512] = {0};
char path[512]; char attach_path[512] = {0};
strncpy(path, g_pending_disk, sizeof(path) - 1); if (g_pending_disk[0] != '\0') { strncpy(autostart_path, g_pending_disk, sizeof(autostart_path)-1); g_pending_disk[0] = '\0'; }
path[sizeof(path) - 1] = '\0'; if (g_pending_attach[0] != '\0') { strncpy(attach_path, g_pending_attach, sizeof(attach_path)-1); g_pending_attach[0] = '\0'; }
g_pending_disk[0] = '\0'; pthread_mutex_unlock(&g_pending_lock);
pthread_mutex_unlock(&g_pending_lock); if (autostart_path[0] != '\0')
LOGI("autostart_disk: %s", path); autostart_disk(8, 0, autostart_path, NULL, 0, AUTOSTART_MODE_RUN);
int asd_ret = autostart_disk(8, 0, path, NULL, 0, AUTOSTART_MODE_RUN); if (attach_path[0] != '\0') {
LOGI("autostart_disk returned %d", asd_ret); int r = file_system_attach_disk(8, 0, attach_path);
} else { if (r == 0) {
pthread_mutex_unlock(&g_pending_lock); 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 >= FRAME_W || yi >= FRAME_H) return;
if (xi + w > FRAME_W) w = FRAME_W - xi; if (xi + w > FRAME_W) w = FRAME_W - xi;
if (yi + h > FRAME_H) h = FRAME_H - yi; 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, (int)xi, (int)yi,
FRAME_W * 4); FRAME_W * 4);
pthread_mutex_unlock(&g_lock); 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. */ /* VICE main loop runs in this thread. */
@@ -353,9 +447,7 @@ static void *stderr_reader_thread(void *arg) {
static void *vice_thread(void *arg) { static void *vice_thread(void *arg) {
(void)arg; (void)arg;
LOGI("vice_thread: starting main_program"); main_program(g_vice_argc, g_vice_argv);
int rc = main_program(g_vice_argc, g_vice_argv);
LOGI("vice_thread: main_program returned %d", rc);
return NULL; 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 * --wrap=archdep_vice_exit redirects all calls here so only the VICE thread
* terminates instead. */ * terminates instead. */
void __wrap_archdep_vice_exit(int code) { void __wrap_archdep_vice_exit(int code) {
LOGI("archdep_vice_exit(%d) — intercepted, terminating VICE thread", code); (void)code;
pthread_exit(NULL); 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_init_main(void) { return __real_init_main(); }
int __wrap_machine_init(void) { return __real_machine_init(); } int __wrap_machine_init(void) { return __real_machine_init(); }
int __wrap_console_init(void) { return __real_console_init(); } int __wrap_console_init(void) { return __real_console_init(); }
#endif /* HAVE_VICE_SRC */ #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) { JNI_FN(jboolean, initEmulator)(JNIEnv *env, jobject obj, jstring romDir) {
#ifdef HAVE_VICE_SRC #ifdef HAVE_VICE_SRC
const char *dir = (*env)->GetStringUTFChars(env, romDir, NULL); const char *dir = (*env)->GetStringUTFChars(env, romDir, NULL);
LOGI("initEmulator: romDir=%s", dir);
/* Prepare argv for VICE: x64 -directory <romDir> -headless */ /* Prepare argv for VICE: x64 -directory <romDir> -headless */
static char dir_copy[512]; 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(src, sizeof(src), "%s/%s", dir_copy, c64_roms[i]);
snprintf(dst, sizeof(dst), "%s/%s", vice_c64_dir, c64_roms[i]); snprintf(dst, sizeof(dst), "%s/%s", vice_c64_dir, c64_roms[i]);
FILE *fsrc = fopen(src, "rb"); FILE *fsrc = fopen(src, "rb");
if (!fsrc) { LOGI("ROM not found: %s", src); continue; } if (!fsrc) continue;
FILE *fdst = fopen(dst, "wb"); 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; char buf[4096]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst); while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
fclose(fsrc); fclose(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). /* 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; char buf[4096]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst); while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
fclose(fdst); fclose(fdst);
LOGI("ROM copied: 1541");
} }
fclose(fsrc); 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 #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. */ /* In thread mode runFrame() is a no-op: VICE drives its own loop. */
JNI_FN(void, runFrame)(JNIEnv *env, jobject obj) { JNI_FN(void, runFrame)(JNIEnv *env, jobject obj) {
(void)env; (void)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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" 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:id="@+id/drawer_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -32,22 +36,20 @@
android:textSize="16sp" /> android:textSize="16sp" />
<TextView <TextView
android:id="@+id/tv_status"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:text="Initialising…" android:text="Schwert und Magie"
android:textColor="#AAFFAA" android:textColor="#FFFFFF"
android:textSize="12sp" /> android:textSize="14sp"
android:textStyle="bold" />
<TextView <View
android:id="@+id/tv_time" android:id="@+id/drive_led"
android:layout_width="wrap_content" android:layout_width="12dp"
android:layout_height="wrap_content" android:layout_height="12dp"
android:text="--:--:--" android:layout_marginEnd="4dp" />
android:textColor="#AAAAFF"
android:textSize="12sp" />
</LinearLayout> </LinearLayout>
<!-- C64 display (320×200, scaled with correct aspect ratio) --> <!-- C64 display (320×200, scaled with correct aspect ratio) -->
@@ -55,15 +57,15 @@
android:id="@+id/c64_display" android:id="@+id/c64_display"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="3" android:layout_weight="2"
android:layout_marginTop="4dp" /> android:layout_marginTop="4dp" />
<!-- Virtual keyboard --> <!-- Virtual keyboard — 6 rows -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64KeyboardView <de.ladkau.schwertundmagieonpebblecompanionapp.C64KeyboardView
android:id="@+id/c64_keyboard" android:id="@+id/c64_keyboard"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="2" android:layout_weight="1.8"
android:layout_marginTop="4dp" /> android:layout_marginTop="4dp" />
<!-- Key-event log --> <!-- Key-event log -->
@@ -96,6 +98,7 @@
<!-- ── Drawer ────────────────────────────────────────────────────────── --> <!-- ── Drawer ────────────────────────────────────────────────────────── -->
<LinearLayout <LinearLayout
android:id="@+id/nav_drawer"
android:layout_width="280dp" android:layout_width="280dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
@@ -110,7 +113,16 @@
android:textColor="#AAFFAA" android:textColor="#AAFFAA"
android:textSize="14sp" android:textSize="14sp"
android:textStyle="bold" 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" <Button android:id="@+id/btn_ep_1a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_width="match_parent" android:layout_height="wrap_content"
@@ -234,3 +246,16 @@
</LinearLayout> </LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout> </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>