diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt index 97b5502..bd31e3f 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt @@ -58,6 +58,9 @@ class C64Engine { /** Monotonically increasing frame counter — incremented once per rendered C64 frame. */ external fun getFrameCount(): Int + /** Current 40x25 C64 text screen as ASCII, rows separated by '\n'. */ + external fun getScreenText(): String + companion object { init { System.loadLibrary("vice_jni") } diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt index 4b0c816..8772535 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.drawerlayout.widget.DrawerLayout import fi.iki.elonen.NanoHTTPD +import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -292,6 +293,33 @@ class MainActivity : AppCompatActivity() { } } + // ---- watch key wheel ------------------------------------------------ + + // Labels sent by the watch's SELECT key wheel (see k_wheel_items in + // SchwertUndMagieOnPebbleFrontend.c) — movement in this game is mostly + // done with number keys, plus RETURN/SPACE to confirm. + private val watchKeyMap: Map> = mapOf( + "1" to listOf(C64Engine.KEY_1), "2" to listOf(C64Engine.KEY_2), + "3" to listOf(C64Engine.KEY_3), "4" to listOf(C64Engine.KEY_4), + "5" to listOf(C64Engine.KEY_5), "6" to listOf(C64Engine.KEY_6), + "7" to listOf(C64Engine.KEY_7), "8" to listOf(C64Engine.KEY_8), + "9" to listOf(C64Engine.KEY_9), "0" to listOf(C64Engine.KEY_0), + "RETURN" to listOf(C64Engine.KEY_RETURN), + "SPACE" to listOf(C64Engine.KEY_SPACE), + ) + + // Pulses a key press+release in response to a watch wheel selection. + // The companion HTTP server calls this off the main thread, so the + // release is posted via mainHandler like the rest of this class's + // cross-thread UI/engine touchpoints. + private fun injectWatchKey(label: String) { + val codes = watchKeyMap[label] ?: return + for (code in codes) engine.injectKey(code, true) + mainHandler.postDelayed({ + for (code in codes) engine.injectKey(code, false) + }, 80) + } + // ---- HTTP server (watch bridge) ----------------------------------------- private fun startHttpServer() { @@ -300,7 +328,9 @@ class MainActivity : AppCompatActivity() { onKey = { cmd -> Log.d(TAG, "Watch key: $cmd") mainHandler.post { appendLog("WATCH: $cmd") } - } + injectWatchKey(cmd) + }, + getScreenText = { engine.getScreenText() } ) try { server?.start() @@ -827,7 +857,8 @@ class CompanionServer( port: Int, private val timeFormat: SimpleDateFormat, private val onTimeFetched: (String) -> Unit, - private val onKey: (String) -> Unit + private val onKey: (String) -> Unit, + private val getScreenText: () -> String ) : NanoHTTPD(port) { override fun serve(session: IHTTPSession): Response { @@ -844,6 +875,10 @@ class CompanionServer( if (cmd.isNotEmpty()) onKey(cmd) newFixedLengthResponse(Response.Status.OK, "text/plain", "ok") } + "/screen" -> { + val body = JSONObject().put("text", getScreenText()).toString() + newFixedLengthResponse(Response.Status.OK, "application/json", body) + } else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found") } response.addHeader("Access-Control-Allow-Origin", "*") diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c index dab8ffa..c20b6ca 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice_jni.c @@ -78,6 +78,8 @@ extern int __real_console_init(void); #include "autostart.h" #include "attach.h" #include "interrupt.h" +#include "charset.h" +#include "mem.h" /* Drive LED state. * g_io_frames: bumped by __wrap_serial_trap_receive; keeps LED lit ~100 ms after @@ -808,6 +810,64 @@ JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) { return (jint)g_frame_count; } +/* Schwert und Magie uploads a custom character set that redefines a handful of + * otherwise-unused PETSCII screen codes (punctuation/graphics glyphs nobody + * needs in German text) to draw umlauts instead. VICE's stock charset tables + * know nothing about this game-specific remap, so those codes must be + * special-cased to the correct UTF-8 character before falling back to the + * standard screencode->PETSCII->ASCII conversion. + * Confirmed by observation — a consecutive run in the unshifted symbol row: + * screencode 0x1B (stock '[') -> ä, 0x1C (stock '£') -> ö, 0x1D (stock ']') -> ü, + * 0x1E (stock '↑') -> ß. + * TODO: add the uppercase variants (Ä, Ö, Ü) once their screen codes are + * identified — likely in the shifted/alternate charset bank. */ +static const struct { uint8_t screencode; const char *utf8; } k_char_overrides[] = { + { 0x1B, "\xC3\xA4" }, /* ä */ + { 0x1C, "\xC3\xB6" }, /* ö */ + { 0x1D, "\xC3\xBC" }, /* ü */ + { 0x1E, "\xC3\x9F" }, /* ß */ +}; + +/* Returns the 40x25 C64 text screen as UTF-8, rows separated by '\n'. + * Reads the fixed default screen address $0400 via mem_read_screen — the same + * helper autostart.c uses to check for KERNAL "READY." text — since this game + * never relocates the VIC-II screen pointer. Screen codes are converted to + * PETSCII then ASCII via VICE's own charset tables (after k_char_overrides is + * checked first); unmappable glyphs (the C64's graphics characters) become '.'. */ +JNI_FN(jstring, getScreenText)(JNIEnv *env, jobject obj) { + (void)obj; +#ifdef HAVE_VICE_SRC + /* Worst case every cell is a 2-byte UTF-8 override: 40*25*2 + 25 newlines + NUL. */ + char text[40 * 25 * 2 + 25 + 1]; + int pos = 0; + for (int row = 0; row < 25; row++) { + for (int col = 0; col < 40; col++) { + uint8_t screencode = mem_read_screen((uint16_t)(0x0400 + row * 40 + col)); + const char *override = NULL; + for (size_t i = 0; i < sizeof(k_char_overrides) / sizeof(k_char_overrides[0]); i++) { + if (k_char_overrides[i].screencode == screencode) { + override = k_char_overrides[i].utf8; + break; + } + } + if (override) { + size_t len = strlen(override); + memcpy(text + pos, override, len); + pos += (int)len; + } else { + uint8_t petscii = charset_screencode_to_petcii(screencode); + text[pos++] = (char)charset_p_toascii(petscii, CONVERT_WITHOUT_CTRLCODES); + } + } + text[pos++] = '\n'; + } + text[pos] = '\0'; + return (*env)->NewStringUTF(env, text); +#else + return (*env)->NewStringUTF(env, ""); +#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; diff --git a/SchwertUndMagieOnPebbleWatchApp/package.json b/SchwertUndMagieOnPebbleWatchApp/package.json index 989fc5e..27aab6c 100644 --- a/SchwertUndMagieOnPebbleWatchApp/package.json +++ b/SchwertUndMagieOnPebbleWatchApp/package.json @@ -24,10 +24,17 @@ }, "messageKeys": [ "TIME", - "COMMAND" + "COMMAND", + "SCREEN" ], "resources": { - "media": [] + "media": [ + { + "type": "bitmap", + "name": "IMAGE_SPLASH", + "file": "splash.png" + } + ] } } } diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/app-icon.png b/SchwertUndMagieOnPebbleWatchApp/res/app-icon.png similarity index 100% rename from SchwertUndMagieOnPebbleCompanionApp/res/app-icon.png rename to SchwertUndMagieOnPebbleWatchApp/res/app-icon.png diff --git a/SchwertUndMagieOnPebbleWatchApp/resources/splash.png b/SchwertUndMagieOnPebbleWatchApp/resources/splash.png new file mode 100644 index 0000000..337241c Binary files /dev/null and b/SchwertUndMagieOnPebbleWatchApp/resources/splash.png differ diff --git a/SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c b/SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c index d694fef..95df5c9 100644 --- a/SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c +++ b/SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c @@ -1,15 +1,39 @@ #include enum { - KEY_TIME = 0, // Inbound: current time string from companion app - KEY_COMMAND = 1 // Outbound: button name sent to companion app + KEY_TIME = 0, // Inbound: current time string from companion app (unused — see KEY_SCREEN) + KEY_COMMAND = 1, // Outbound: button name sent to companion app + KEY_SCREEN = 2 // Inbound: C64 text screen (40x25, ASCII, rows separated by '\n') }; -static Window *s_main_window; -static TextLayer *s_time_layer; -static TextLayer *s_hint_layer; +#define SCREEN_FONT FONT_KEY_GOTHIC_24 -static char s_time_buffer[32] = "Waiting..."; +// Curated navigation-only key set for the SELECT wheel. Movement in this +// game is mostly done with number keys, plus RETURN/SPACE to confirm. +static const char *const k_wheel_items[] = { + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "RETURN", "SPACE" +}; +#define WHEEL_ITEM_COUNT ((int)(sizeof(k_wheel_items) / sizeof(k_wheel_items[0]))) + +static Window *s_main_window; +static ScrollLayer *s_scroll_layer; +static TextLayer *s_screen_layer; + +static Window *s_wheel_window; +static TextLayer *s_wheel_label_layer; +static TextLayer *s_wheel_hint_layer; +static int s_wheel_index = 0; + +#define SPLASH_DURATION_MS 1800 + +static Window *s_splash_window; +static BitmapLayer *s_splash_bitmap_layer; +static GBitmap *s_splash_bitmap; + +// Worst case 40*25 cells at 2 UTF-8 bytes each (umlaut overrides) + 25 +// row-separator newlines + NUL, matching the companion app's getScreenText() +// output (see vice_jni.c). +static char s_screen_buffer[2080] = "Waiting for C64 screen..."; static void send_key_to_phone(const char *key_str) { DictionaryIterator *iter; @@ -19,69 +43,183 @@ static void send_key_to_phone(const char *key_str) { app_message_outbox_send(); } +// Resize the text layer and scroll layer's content size to fit s_screen_buffer +// at the current font/width. Must run whenever the buffer's text changes — +// the C64 screen content (and therefore wrapped height) varies frame to frame. +static void update_screen_layout(void) { + GRect bounds = layer_get_bounds(scroll_layer_get_layer(s_scroll_layer)); + GFont font = fonts_get_system_font(SCREEN_FONT); + GRect measure_box = GRect(0, 0, bounds.size.w, 4000); + GSize content_size = graphics_text_layout_get_content_size( + s_screen_buffer, font, measure_box, GTextOverflowModeWordWrap, GTextAlignmentLeft); + // Floor content height at the viewport height so short text doesn't shrink + // the scrollable area below what's visible. + int16_t height = content_size.h > bounds.size.h ? content_size.h : bounds.size.h; + + layer_set_frame(text_layer_get_layer(s_screen_layer), GRect(0, 0, bounds.size.w, height)); + scroll_layer_set_content_size(s_scroll_layer, GSize(bounds.size.w, height)); +} + static void inbox_received_callback(DictionaryIterator *iterator, void *context) { - Tuple *time_tuple = dict_find(iterator, KEY_TIME); - if (time_tuple && time_tuple->type == TUPLE_CSTRING) { - snprintf(s_time_buffer, sizeof(s_time_buffer), "%s", time_tuple->value->cstring); - text_layer_set_text(s_time_layer, s_time_buffer); - layer_mark_dirty(text_layer_get_layer(s_time_layer)); + Tuple *screen_tuple = dict_find(iterator, KEY_SCREEN); + if (screen_tuple && screen_tuple->type == TUPLE_CSTRING) { + snprintf(s_screen_buffer, sizeof(s_screen_buffer), "%s", screen_tuple->value->cstring); + text_layer_set_text(s_screen_layer, s_screen_buffer); + update_screen_layout(); + layer_mark_dirty(text_layer_get_layer(s_screen_layer)); } } -static void up_click_handler(ClickRecognizerRef recognizer, void *context) { - send_key_to_phone("UP"); +static void update_wheel_label(void) { + text_layer_set_text(s_wheel_label_layer, k_wheel_items[s_wheel_index]); } -static void down_click_handler(ClickRecognizerRef recognizer, void *context) { - send_key_to_phone("DOWN"); +static void wheel_up_click_handler(ClickRecognizerRef recognizer, void *context) { + s_wheel_index = (s_wheel_index - 1 + WHEEL_ITEM_COUNT) % WHEEL_ITEM_COUNT; + update_wheel_label(); +} + +static void wheel_down_click_handler(ClickRecognizerRef recognizer, void *context) { + s_wheel_index = (s_wheel_index + 1) % WHEEL_ITEM_COUNT; + update_wheel_label(); +} + +// Sends the highlighted item and pops back to the screen mirror. The BACK +// button is left unsubscribed so its default behavior (pop the window) acts +// as "cancel" for free. +static void wheel_select_click_handler(ClickRecognizerRef recognizer, void *context) { + send_key_to_phone(k_wheel_items[s_wheel_index]); + window_stack_pop(true); +} + +static void wheel_click_config_provider(void *context) { + window_single_click_subscribe(BUTTON_ID_UP, wheel_up_click_handler); + window_single_click_subscribe(BUTTON_ID_DOWN, wheel_down_click_handler); + window_single_click_subscribe(BUTTON_ID_SELECT, wheel_select_click_handler); +} + +static void wheel_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + + s_wheel_label_layer = text_layer_create(GRect(0, bounds.size.h / 2 - 24, bounds.size.w, 48)); + text_layer_set_font(s_wheel_label_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK)); + text_layer_set_text_alignment(s_wheel_label_layer, GTextAlignmentCenter); + update_wheel_label(); + layer_add_child(window_layer, text_layer_get_layer(s_wheel_label_layer)); + + s_wheel_hint_layer = text_layer_create(GRect(0, bounds.size.h - 30, bounds.size.w, 30)); + text_layer_set_font(s_wheel_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14)); + text_layer_set_text_alignment(s_wheel_hint_layer, GTextAlignmentCenter); + text_layer_set_text(s_wheel_hint_layer, "UP/DOWN spin - SELECT send"); + layer_add_child(window_layer, text_layer_get_layer(s_wheel_hint_layer)); +} + +static void wheel_window_unload(Window *window) { + text_layer_destroy(s_wheel_label_layer); + text_layer_destroy(s_wheel_hint_layer); } static void select_click_handler(ClickRecognizerRef recognizer, void *context) { - send_key_to_phone("SELECT"); + window_stack_push(s_wheel_window, true); } -static void click_config_provider(void *context) { - window_single_click_subscribe(BUTTON_ID_UP, up_click_handler); - window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler); +// Passed to scroll_layer_set_callbacks(): the ScrollLayer's own click config +// provider calls this after wiring UP/DOWN to scroll, so we only need to add +// SELECT here — UP/DOWN stay dedicated to panning the text view. +static void scroll_click_config_provider(void *context) { window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler); } +static void splash_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + + s_splash_bitmap = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_SPLASH); + s_splash_bitmap_layer = bitmap_layer_create(bounds); + bitmap_layer_set_bitmap(s_splash_bitmap_layer, s_splash_bitmap); + // Source image is roughly square and smaller than every target screen, so + // center it without stretching rather than filling/distorting the frame. + bitmap_layer_set_alignment(s_splash_bitmap_layer, GAlignCenter); + bitmap_layer_set_background_color(s_splash_bitmap_layer, GColorBlack); + layer_add_child(window_layer, bitmap_layer_get_layer(s_splash_bitmap_layer)); +} + +static void splash_window_unload(Window *window) { + bitmap_layer_destroy(s_splash_bitmap_layer); + gbitmap_destroy(s_splash_bitmap); +} + +// main_window is pushed underneath splash_window at startup (see init()), so +// popping splash here just reveals it — the stack is never empty, which +// matters because removing the last window on the stack kills the app. +static void splash_timeout_handler(void *data) { + window_stack_remove(s_splash_window, true); +} + static void main_window_load(Window *window) { Layer *window_layer = window_get_root_layer(window); GRect bounds = layer_get_bounds(window_layer); - s_time_layer = text_layer_create(GRect(0, 55, bounds.size.w, 50)); - text_layer_set_text(s_time_layer, s_time_buffer); - text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK)); - text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter); - layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); + s_scroll_layer = scroll_layer_create(bounds); + scroll_layer_set_click_config_onto_window(s_scroll_layer, window); + scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks) { + .click_config_provider = scroll_click_config_provider + }); - s_hint_layer = text_layer_create(GRect(0, 130, bounds.size.w, 30)); - text_layer_set_text(s_hint_layer, "UP / DN / SEL"); - text_layer_set_font(s_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14)); - text_layer_set_text_alignment(s_hint_layer, GTextAlignmentCenter); - layer_add_child(window_layer, text_layer_get_layer(s_hint_layer)); + // The 40-column C64 grid is not preserved — the companion app's text + // already has '\n' per row, but at this font size lines reflow + // (word-wrap) rather than line up like the original screen. + s_screen_layer = text_layer_create(GRect(0, 0, bounds.size.w, bounds.size.h)); + text_layer_set_text(s_screen_layer, s_screen_buffer); + text_layer_set_font(s_screen_layer, fonts_get_system_font(SCREEN_FONT)); + text_layer_set_text_alignment(s_screen_layer, GTextAlignmentLeft); + scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(s_screen_layer)); + + update_screen_layout(); + layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer)); } static void main_window_unload(Window *window) { - text_layer_destroy(s_time_layer); - text_layer_destroy(s_hint_layer); + text_layer_destroy(s_screen_layer); + scroll_layer_destroy(s_scroll_layer); } static void init(void) { s_main_window = window_create(); - window_set_click_config_provider(s_main_window, click_config_provider); window_set_window_handlers(s_main_window, (WindowHandlers) { .load = main_window_load, .unload = main_window_unload }); window_stack_push(s_main_window, true); + s_wheel_window = window_create(); + window_set_click_config_provider(s_wheel_window, wheel_click_config_provider); + window_set_window_handlers(s_wheel_window, (WindowHandlers) { + .load = wheel_window_load, + .unload = wheel_window_unload + }); + + s_splash_window = window_create(); + window_set_window_handlers(s_splash_window, (WindowHandlers) { + .load = splash_window_load, + .unload = splash_window_unload + }); + // Pushed on top of the already-present main_window, not in place of an + // empty stack — see the note on splash_timeout_handler below. + window_stack_push(s_splash_window, true); + app_timer_register(SPLASH_DURATION_MS, splash_timeout_handler, NULL); + app_message_register_inbox_received(inbox_received_callback); - app_message_open(128, 64); + // Inbox must hold the full 40x25 screen text (up to ~2080 bytes with UTF-8 + // umlaut overrides) plus dictionary overhead; outbox only ever carries a + // short button-name string. + app_message_open(2200, 64); } static void deinit(void) { + window_destroy(s_splash_window); + window_destroy(s_wheel_window); window_destroy(s_main_window); } diff --git a/SchwertUndMagieOnPebbleWatchApp/src/pkjs/index.js b/SchwertUndMagieOnPebbleWatchApp/src/pkjs/index.js index b8c1b1f..d72df1f 100644 --- a/SchwertUndMagieOnPebbleWatchApp/src/pkjs/index.js +++ b/SchwertUndMagieOnPebbleWatchApp/src/pkjs/index.js @@ -2,6 +2,7 @@ var SERVER = 'http://127.0.0.1:8888'; var KEY_TIME = 0; var KEY_COMMAND = 1; +var KEY_SCREEN = 2; function httpGet(url) { var xhr = new XMLHttpRequest(); @@ -12,15 +13,15 @@ function httpGet(url) { xhr.send(); } -function sendTimeToWatch() { +function sendScreenToWatch() { var xhr = new XMLHttpRequest(); - xhr.open('GET', SERVER + '/time', true); + xhr.open('GET', SERVER + '/screen', true); xhr.onload = function() { if (xhr.status === 200) { try { var data = JSON.parse(xhr.responseText); var msg = {}; - msg[KEY_TIME] = data.time; + msg[KEY_SCREEN] = data.text; Pebble.sendAppMessage( msg, function() {}, @@ -32,13 +33,13 @@ function sendTimeToWatch() { } }; xhr.onerror = function() { - console.log('[SuM] GET /time failed - is the companion app running?'); + console.log('[SuM] GET /screen failed - is the companion app running?'); }; xhr.send(); } Pebble.addEventListener('ready', function() { - setInterval(sendTimeToWatch, 1000); + setInterval(sendScreenToWatch, 1000); }); Pebble.addEventListener('appmessage', function(e) { diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..917e20d --- /dev/null +++ b/architecture.md @@ -0,0 +1,259 @@ +# Architecture + +This document describes the current runtime architecture of the two apps in this +repo: the **Pebble watch app** and the **Android companion app**. For build/install +commands, see `CLAUDE.md`. + +## 1. System overview + +Three processes cooperate across two devices. **Core for Pebble** is a separate +app on the phone (not part of this repo) that bridges Bluetooth AppMessage traffic +to a JS runtime; our companion app talks to it only via loopback HTTP. + +```mermaid +graph LR + subgraph Watch["Pebble Time 2 (watch)"] + WatchC["Watch app (C)
splash / main / wheel windows"] + end + + subgraph Phone["Android phone"] + CFP["Core for Pebble
(PebbleKit JS runtime, separate app)"] + + subgraph Companion["Companion app process"] + HTTP["NanoHTTPD server :8888"] + UI["MainActivity / UI
C64DisplayView, C64KeyboardView"] + JNI["JNI bridge
vice_jni.c"] + VICE["VICE C64 core
(own pthread)"] + end + end + + WatchC <-->|Bluetooth AppMessage| CFP + CFP <-->|HTTP loopback 127.0.0.1:8888| HTTP + HTTP --> UI + UI --> JNI + JNI <--> VICE +``` + +## 2. Pebble watch app + +`SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c` is a +single-file C watchapp built around three `Window`s on a shared stack, plus +`src/pkjs/index.js` running inside Core for Pebble. + +```mermaid +stateDiagram-v2 + [*] --> Splash + Splash --> Main: 1800ms timer\n(window_stack_remove splash) + Main --> Wheel: SELECT\n(push wheel) + Wheel --> Main: SELECT (send + pop)\nor BACK (cancel, pop) +``` + +- **Splash** — `BitmapLayer` showing `resources/splash.png`, centered, black + backdrop. Pushed *on top of* the already-pushed Main window at startup (not + in place of an empty stack — removing the last window on the stack kills the + app), then removed by an `AppTimer` after 1.8s. +- **Main** — a `ScrollLayer` wrapping a `TextLayer` that mirrors the C64 text + screen. UP/DOWN are claimed entirely by the ScrollLayer's built-in click + config (pan only); SELECT is added via `scroll_layer_set_callbacks()`'s + `click_config_provider` hook and opens the wheel. Content height is + recomputed via `graphics_text_layout_get_content_size()` every time new + screen text arrives, since wrapped height varies frame to frame. +- **Wheel** — a single large `TextLayer` cycling through a curated, + navigation-only key set: `1`–`9`, `0`, `RETURN`, `SPACE` (movement in this + game is mostly done with number keys). UP/DOWN rotate the index, SELECT + sends the highlighted label and pops back to Main, BACK cancels for free + (default window-pop behavior, left unsubscribed). + +### AppMessage protocol + +| Key | Value | Direction | Payload | +|---|---|---|---| +| `TIME` | 0 | — | Unused (legacy; originally the clock, replaced by `SCREEN`) | +| `COMMAND` | 1 | watch → phone | Wheel item label: one of `1`..`9`, `0`, `RETURN`, `SPACE` | +| `SCREEN` | 2 | phone → watch | UTF-8 C64 screen text, 40×25 cells, `\n` per row | + +Numeric keys are hardcoded identically in the C app and `index.js` — symbolic +key resolution from `package.json`'s `messageKeys` is unreliable with Core for +Pebble. The watch's AppMessage inbox is opened at 2200 bytes to fit the +worst-case screen payload (40×25 cells × up to 2 UTF-8 bytes for umlaut +overrides, + 25 newlines). + +## 3. Android companion app + +```mermaid +graph TD + Main["Main/UI thread
Choreographer vsync loop, touch events"] + Vice["VICE thread
main_program() → maincpu_mainloop()"] + Http["NanoHTTPD worker thread(s)
one per request"] + StdoutT["stdout reader thread"] + StderrT["stderr reader thread"] + Audio["OpenSL ES callback thread"] + + Main -->|"captureFrame(): reads g_framebuf"| Vice + Main -->|"injectKey(): writes keyboard matrix"| Vice + Http -->|"onKey → injectWatchKey → injectKey"| Vice + Http -->|"getScreenText(): reads C64 RAM"| Vice + Vice -->|"writes g_framebuf, drains pending queues"| Main + Vice -->|stdout/stderr pipes| StdoutT + Vice -->|stdout/stderr pipes| StderrT + Vice -->|sound ring buffer| Audio +``` + +- **Main/UI thread** — `MainActivity`'s `Choreographer.postFrameCallback` loop + drives rendering: on every hardware vsync it calls `display.captureFrame(engine)` + (copies VICE's 320×200 ARGB framebuffer into a Bitmap) and `invalidate()`. + VICE itself runs continuously and asynchronously in its own thread, decoupled + from this vsync sampling. Touch input (`C64KeyboardView`, disk drawer + buttons) and NanoHTTPD callbacks (marshaled via `mainHandler`) also run here. +- **VICE thread** (`vice_thread` in `vice_jni.c`) — runs `main_program()` → + `maincpu_mainloop()`, VICE's own CPU/VICII loop. `video_canvas_refresh()` is + our hook into this loop, called once per rendered region: it drains + mutex-guarded pending-operation queues (disk autostart/attach, hard reset, + snapshot save/load) written from the UI or HTTP threads, then renders into + `g_framebuf`. +- **NanoHTTPD worker thread(s)** — `CompanionServer` (in `MainActivity.kt`) + serves `/time`, `/key?cmd=`, `/screen` to Core for Pebble's JS. `onKey` and + `getScreenText` call directly into the JNI layer from this thread (see + §5 on tolerated races). +- **stdout/stderr reader threads** — pipe VICE's redirected stdout/stderr to + Logcat under tag `ViceJNI`, prefixed `VICE: `. +- **OpenSL ES callback thread** — pulls PCM samples from a ring buffer filled + by VICE's registered `android` sound driver. + +### Key source files + +| File | Role | +|---|---| +| `MainActivity.kt` | UI, Choreographer render loop, NanoHTTPD server, disk drawer, watch key wheel → `injectKey` mapping | +| `C64Engine.kt` | JNI external-function declarations + C64 keyboard matrix constants | +| `C64DisplayView.kt` | Double-buffered View blitting the 320×200 ARGB framebuffer | +| `C64KeyboardView.kt` | On-screen virtual C64 keyboard (multi-touch, sticky shift) | +| `vice_jni.c` | VICE integration: thread management, video/sound drivers, pending-op queues, snapshot CPU-trap dispatch, `getScreenText()` | + +## 4. Data flows + +### 4.1 Screen mirror (VICE → watch) + +```mermaid +sequenceDiagram + participant VICE as VICE thread + participant JNI as vice_jni.c + participant HTTP as NanoHTTPD /screen + participant JS as PebbleKit JS + participant Watch as Watch app (KEY_SCREEN) + + loop every 1s + JS->>HTTP: GET /screen + HTTP->>JNI: getScreenText() + JNI->>VICE: mem_read_screen($0400..$07E7) + JNI-->>HTTP: UTF-8 text (40x25, \n per row) + HTTP-->>JS: {"text": "..."} + JS->>Watch: AppMessage KEY_SCREEN + Watch->>Watch: update ScrollLayer/TextLayer + end +``` + +`getScreenText()` reads C64 screen RAM at the fixed default address `$0400` +(same assumption `autostart.c` makes when checking for KERNAL "READY." text — +this game never relocates the VIC-II screen pointer) and converts each +screencode → PETSCII → ASCII via VICE's own `charset.c` tables. The game +uploads a **custom character set** that redefines a consecutive run of +otherwise-unused screencodes to draw German umlauts; a small override table in +`getScreenText()` catches these and emits proper UTF-8 before falling through +to the standard conversion: + +| Screencode | Stock glyph | Overridden to | +|---|---|---| +| `0x1B` | `[` | ä | +| `0x1C` | `£` | ö | +| `0x1D` | `]` | ü | +| `0x1E` | `↑` | ß | + +### 4.2 On-screen keyboard input (phone touch → VICE) + +```mermaid +sequenceDiagram + participant User + participant KB as C64KeyboardView + participant Main as MainActivity + participant JNI as vice_jni.c + participant VICE as VICE keyboard matrix + + User->>KB: touch down/up on key + KB->>Main: onKeyEvent(key, pressed) + Main->>JNI: engine.injectKey(code, pressed) (per code, for composites) + JNI->>VICE: keyboard_set_keyarr(row, col, pressed) +``` + +Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are +pressed/released together. + +### 4.3 Watch key wheel input (watch → VICE) + +```mermaid +sequenceDiagram + participant Watch + participant JS as PebbleKit JS + participant HTTP as NanoHTTPD /key + participant Main as MainActivity + participant JNI as vice_jni.c + + Watch->>Watch: SELECT opens wheel; UP/DOWN rotate; SELECT confirms + Watch->>JS: AppMessage KEY_COMMAND = "