Add screen mirroring, watch input, splash screen, and architecture docs
Mirrors the C64 text screen to the Pebble watch (with game-specific umlaut handling), adds a SELECT-triggered key wheel for sending navigation input back to the emulator, shows a splash screen on watch launch, and documents the resulting end-to-end architecture.
This commit is contained in:
+3
@@ -58,6 +58,9 @@ class C64Engine {
|
|||||||
/** Monotonically increasing frame counter — incremented once per rendered C64 frame. */
|
/** Monotonically increasing frame counter — incremented once per rendered C64 frame. */
|
||||||
external fun getFrameCount(): Int
|
external fun getFrameCount(): Int
|
||||||
|
|
||||||
|
/** Current 40x25 C64 text screen as ASCII, rows separated by '\n'. */
|
||||||
|
external fun getScreenText(): String
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init { System.loadLibrary("vice_jni") }
|
init { System.loadLibrary("vice_jni") }
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -29,6 +29,7 @@ import androidx.core.view.ViewCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import fi.iki.elonen.NanoHTTPD
|
import fi.iki.elonen.NanoHTTPD
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
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<String, List<Int>> = 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) -----------------------------------------
|
// ---- HTTP server (watch bridge) -----------------------------------------
|
||||||
|
|
||||||
private fun startHttpServer() {
|
private fun startHttpServer() {
|
||||||
@@ -300,7 +328,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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") }
|
||||||
}
|
injectWatchKey(cmd)
|
||||||
|
},
|
||||||
|
getScreenText = { engine.getScreenText() }
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
server?.start()
|
server?.start()
|
||||||
@@ -827,7 +857,8 @@ class CompanionServer(
|
|||||||
port: Int,
|
port: Int,
|
||||||
private val timeFormat: SimpleDateFormat,
|
private val timeFormat: SimpleDateFormat,
|
||||||
private val onTimeFetched: (String) -> Unit,
|
private val onTimeFetched: (String) -> Unit,
|
||||||
private val onKey: (String) -> Unit
|
private val onKey: (String) -> Unit,
|
||||||
|
private val getScreenText: () -> String
|
||||||
) : NanoHTTPD(port) {
|
) : NanoHTTPD(port) {
|
||||||
|
|
||||||
override fun serve(session: IHTTPSession): Response {
|
override fun serve(session: IHTTPSession): Response {
|
||||||
@@ -844,6 +875,10 @@ class CompanionServer(
|
|||||||
if (cmd.isNotEmpty()) onKey(cmd)
|
if (cmd.isNotEmpty()) onKey(cmd)
|
||||||
newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
|
newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
|
||||||
}
|
}
|
||||||
|
"/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")
|
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
|
||||||
}
|
}
|
||||||
response.addHeader("Access-Control-Allow-Origin", "*")
|
response.addHeader("Access-Control-Allow-Origin", "*")
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ extern int __real_console_init(void);
|
|||||||
#include "autostart.h"
|
#include "autostart.h"
|
||||||
#include "attach.h"
|
#include "attach.h"
|
||||||
#include "interrupt.h"
|
#include "interrupt.h"
|
||||||
|
#include "charset.h"
|
||||||
|
#include "mem.h"
|
||||||
|
|
||||||
/* Drive LED state.
|
/* Drive LED state.
|
||||||
* g_io_frames: bumped by __wrap_serial_trap_receive; keeps LED lit ~100 ms after
|
* 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;
|
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. */
|
/* 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;
|
||||||
|
|||||||
@@ -24,10 +24,17 @@
|
|||||||
},
|
},
|
||||||
"messageKeys": [
|
"messageKeys": [
|
||||||
"TIME",
|
"TIME",
|
||||||
"COMMAND"
|
"COMMAND",
|
||||||
|
"SCREEN"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"media": []
|
"media": [
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMAGE_SPLASH",
|
||||||
|
"file": "splash.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 405 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -1,15 +1,39 @@
|
|||||||
#include <pebble.h>
|
#include <pebble.h>
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
KEY_TIME = 0, // Inbound: current time string from 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_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;
|
#define SCREEN_FONT FONT_KEY_GOTHIC_24
|
||||||
static TextLayer *s_time_layer;
|
|
||||||
static TextLayer *s_hint_layer;
|
|
||||||
|
|
||||||
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) {
|
static void send_key_to_phone(const char *key_str) {
|
||||||
DictionaryIterator *iter;
|
DictionaryIterator *iter;
|
||||||
@@ -19,69 +43,183 @@ static void send_key_to_phone(const char *key_str) {
|
|||||||
app_message_outbox_send();
|
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) {
|
static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
|
||||||
Tuple *time_tuple = dict_find(iterator, KEY_TIME);
|
Tuple *screen_tuple = dict_find(iterator, KEY_SCREEN);
|
||||||
if (time_tuple && time_tuple->type == TUPLE_CSTRING) {
|
if (screen_tuple && screen_tuple->type == TUPLE_CSTRING) {
|
||||||
snprintf(s_time_buffer, sizeof(s_time_buffer), "%s", time_tuple->value->cstring);
|
snprintf(s_screen_buffer, sizeof(s_screen_buffer), "%s", screen_tuple->value->cstring);
|
||||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
text_layer_set_text(s_screen_layer, s_screen_buffer);
|
||||||
layer_mark_dirty(text_layer_get_layer(s_time_layer));
|
update_screen_layout();
|
||||||
|
layer_mark_dirty(text_layer_get_layer(s_screen_layer));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
static void update_wheel_label(void) {
|
||||||
send_key_to_phone("UP");
|
text_layer_set_text(s_wheel_label_layer, k_wheel_items[s_wheel_index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
|
static void wheel_up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||||
send_key_to_phone("DOWN");
|
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) {
|
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) {
|
// Passed to scroll_layer_set_callbacks(): the ScrollLayer's own click config
|
||||||
window_single_click_subscribe(BUTTON_ID_UP, up_click_handler);
|
// provider calls this after wiring UP/DOWN to scroll, so we only need to add
|
||||||
window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler);
|
// 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);
|
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) {
|
static void main_window_load(Window *window) {
|
||||||
Layer *window_layer = window_get_root_layer(window);
|
Layer *window_layer = window_get_root_layer(window);
|
||||||
GRect bounds = layer_get_bounds(window_layer);
|
GRect bounds = layer_get_bounds(window_layer);
|
||||||
|
|
||||||
s_time_layer = text_layer_create(GRect(0, 55, bounds.size.w, 50));
|
s_scroll_layer = scroll_layer_create(bounds);
|
||||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
|
||||||
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK));
|
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks) {
|
||||||
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
|
.click_config_provider = scroll_click_config_provider
|
||||||
layer_add_child(window_layer, text_layer_get_layer(s_time_layer));
|
});
|
||||||
|
|
||||||
s_hint_layer = text_layer_create(GRect(0, 130, bounds.size.w, 30));
|
// The 40-column C64 grid is not preserved — the companion app's text
|
||||||
text_layer_set_text(s_hint_layer, "UP / DN / SEL");
|
// already has '\n' per row, but at this font size lines reflow
|
||||||
text_layer_set_font(s_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14));
|
// (word-wrap) rather than line up like the original screen.
|
||||||
text_layer_set_text_alignment(s_hint_layer, GTextAlignmentCenter);
|
s_screen_layer = text_layer_create(GRect(0, 0, bounds.size.w, bounds.size.h));
|
||||||
layer_add_child(window_layer, text_layer_get_layer(s_hint_layer));
|
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) {
|
static void main_window_unload(Window *window) {
|
||||||
text_layer_destroy(s_time_layer);
|
text_layer_destroy(s_screen_layer);
|
||||||
text_layer_destroy(s_hint_layer);
|
scroll_layer_destroy(s_scroll_layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void init(void) {
|
static void init(void) {
|
||||||
s_main_window = window_create();
|
s_main_window = window_create();
|
||||||
window_set_click_config_provider(s_main_window, click_config_provider);
|
|
||||||
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
||||||
.load = main_window_load,
|
.load = main_window_load,
|
||||||
.unload = main_window_unload
|
.unload = main_window_unload
|
||||||
});
|
});
|
||||||
window_stack_push(s_main_window, true);
|
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_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) {
|
static void deinit(void) {
|
||||||
|
window_destroy(s_splash_window);
|
||||||
|
window_destroy(s_wheel_window);
|
||||||
window_destroy(s_main_window);
|
window_destroy(s_main_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ var SERVER = 'http://127.0.0.1:8888';
|
|||||||
|
|
||||||
var KEY_TIME = 0;
|
var KEY_TIME = 0;
|
||||||
var KEY_COMMAND = 1;
|
var KEY_COMMAND = 1;
|
||||||
|
var KEY_SCREEN = 2;
|
||||||
|
|
||||||
function httpGet(url) {
|
function httpGet(url) {
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
@@ -12,15 +13,15 @@ function httpGet(url) {
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTimeToWatch() {
|
function sendScreenToWatch() {
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', SERVER + '/time', true);
|
xhr.open('GET', SERVER + '/screen', true);
|
||||||
xhr.onload = function() {
|
xhr.onload = function() {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
try {
|
try {
|
||||||
var data = JSON.parse(xhr.responseText);
|
var data = JSON.parse(xhr.responseText);
|
||||||
var msg = {};
|
var msg = {};
|
||||||
msg[KEY_TIME] = data.time;
|
msg[KEY_SCREEN] = data.text;
|
||||||
Pebble.sendAppMessage(
|
Pebble.sendAppMessage(
|
||||||
msg,
|
msg,
|
||||||
function() {},
|
function() {},
|
||||||
@@ -32,13 +33,13 @@ function sendTimeToWatch() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = function() {
|
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();
|
xhr.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
Pebble.addEventListener('ready', function() {
|
Pebble.addEventListener('ready', function() {
|
||||||
setInterval(sendTimeToWatch, 1000);
|
setInterval(sendScreenToWatch, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
Pebble.addEventListener('appmessage', function(e) {
|
Pebble.addEventListener('appmessage', function(e) {
|
||||||
|
|||||||
+259
@@ -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)<br/>splash / main / wheel windows"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Phone["Android phone"]
|
||||||
|
CFP["Core for Pebble<br/>(PebbleKit JS runtime, separate app)"]
|
||||||
|
|
||||||
|
subgraph Companion["Companion app process"]
|
||||||
|
HTTP["NanoHTTPD server :8888"]
|
||||||
|
UI["MainActivity / UI<br/>C64DisplayView, C64KeyboardView"]
|
||||||
|
JNI["JNI bridge<br/>vice_jni.c"]
|
||||||
|
VICE["VICE C64 core<br/>(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<br/>Choreographer vsync loop, touch events"]
|
||||||
|
Vice["VICE thread<br/>main_program() → maincpu_mainloop()"]
|
||||||
|
Http["NanoHTTPD worker thread(s)<br/>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 = "<label>"
|
||||||
|
JS->>HTTP: GET /key?cmd=<label>
|
||||||
|
HTTP->>Main: onKey(cmd)
|
||||||
|
Main->>Main: watchKeyMap[cmd] → codes
|
||||||
|
Main->>JNI: injectKey(code, true) for each code
|
||||||
|
Main->>Main: postDelayed 80ms
|
||||||
|
Main->>JNI: injectKey(code, false) for each code
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Snapshot save/load (CPU-trap register sync)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as MainActivity (save/load button)
|
||||||
|
participant Pending as g_pending_save_state / g_pending_load_state
|
||||||
|
participant Refresh as video_canvas_refresh (VICE thread)
|
||||||
|
participant Trap as interrupt_maincpu_trigger_trap
|
||||||
|
participant CPU as 6510core.c DO_INTERRUPT(IK_TRAP)
|
||||||
|
|
||||||
|
UI->>Pending: saveState(path) / loadState(path) [mutex-guarded]
|
||||||
|
Refresh->>Pending: drain pending path next frame
|
||||||
|
Refresh->>Trap: schedule save_state_trap / load_state_trap
|
||||||
|
CPU->>CPU: EXPORT_REGISTERS()
|
||||||
|
CPU->>Trap: run trap function
|
||||||
|
Trap->>Trap: machine_write_snapshot() / machine_read_snapshot()
|
||||||
|
CPU->>CPU: IMPORT_REGISTERS()
|
||||||
|
Note over CPU: reg_pc now matches the saved/restored maincpu_regs.pc
|
||||||
|
```
|
||||||
|
|
||||||
|
Both save and load **must** run inside a CPU trap. `maincpu_mainloop()` keeps
|
||||||
|
CPU registers as stack-local variables (`reg_pc`, `reg_a`, ...), syncing them
|
||||||
|
with the global `maincpu_regs` struct only via `EXPORT_REGISTERS()` /
|
||||||
|
`IMPORT_REGISTERS()` inside `DO_INTERRUPT`. Calling `machine_write_snapshot`/
|
||||||
|
`machine_read_snapshot` directly from `video_canvas_refresh()` (outside a trap)
|
||||||
|
would read/write a stale `maincpu_regs.pc` — the snapshot would record (or
|
||||||
|
restore) the wrong program counter, leaving the CPU executing from the wrong
|
||||||
|
address after a load even though screen/CIA/SID state all looked correct.
|
||||||
|
|
||||||
|
### 4.5 Disk load / attach / reset
|
||||||
|
|
||||||
|
`loadDisk()` (full reset + autostart, used for A-side episode disks) and
|
||||||
|
`attachDisk()` (hot-swap, used for B-side/hero disks) both just write a path
|
||||||
|
into a mutex-guarded pending buffer; `video_canvas_refresh()` drains it on the
|
||||||
|
VICE thread and calls `autostart_disk()` or `file_system_attach_disk()`
|
||||||
|
accordingly — disk and reset APIs, like snapshot APIs, must only be called
|
||||||
|
from the VICE thread.
|
||||||
|
|
||||||
|
## 5. Tolerated cross-thread races
|
||||||
|
|
||||||
|
Two JNI calls are invoked directly from non-VICE threads with no locking:
|
||||||
|
`injectKey()` (writes the keyboard matrix from the UI thread *or* an HTTP
|
||||||
|
worker thread) and `getScreenText()` (reads screen RAM from an HTTP worker
|
||||||
|
thread). Both are deliberate: a keyboard matrix write or a screen-text read
|
||||||
|
racing with the VICE thread can produce at most one stale byte for one frame,
|
||||||
|
which self-corrects on the next poll/keypress — acceptable for display and
|
||||||
|
input purposes. This is a different category from §4.4: snapshot register
|
||||||
|
sync is correctness-critical (a wrong PC corrupts execution permanently), so
|
||||||
|
it goes through the CPU trap; keyboard/display reads are not, so they don't.
|
||||||
Reference in New Issue
Block a user