/*
 * JNI bridge for VICE headless C64 emulation.
 *
 * Without VICE source (stub mode):
 *   initEmulator fills g_framebuf with a solid C64-blue — the app builds
 *   and runs normally; the display shows a placeholder.
 *
 * With VICE source (HAVE_VICE_SRC=1, set automatically by CMakeLists.txt
 * when vice-libs/<abi>/libvice.a exists):
 *   VICE runs in its own thread started by initEmulator().
 *   The custom video canvas callback writes each frame into g_framebuf.
 *   getVideoBuffer() snapshots g_framebuf into the Java ByteBuffer.
 *   injectKey() writes directly to VICE's keyboard matrix.
 */

#include <jni.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/stat.h>
#include <android/log.h>
#include <SLES/OpenSLES.h>
#include <SLES/OpenSLES_Android.h>

#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,  "ViceJNI", __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "ViceJNI", __VA_ARGS__)

#define FRAME_W 320
#define FRAME_H 200

static uint32_t        g_framebuf[FRAME_W * FRAME_H];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static int             g_ready = 0;

/* Pending disk/reset/snapshot operations — written by Android thread, consumed by
 * video_canvas_refresh on the VICE thread (machine_trigger_reset, disk APIs, and
 * snapshot APIs must be called from the VICE thread to avoid corrupting state).
 * g_pending_disk       → autostart_disk (resets machine, used for A-side disks)
 * g_pending_attach     → file_system_attach_disk (hot-swap, B-side / hero disks)
 * g_pending_reset      → detach disk + hard reset to BASIC prompt
 * g_pending_save_state → machine_write_snapshot (save full machine state)
 * g_pending_load_state → machine_read_snapshot  (restore full machine state) */
static pthread_mutex_t g_pending_lock       = PTHREAD_MUTEX_INITIALIZER;
static char            g_pending_disk[512];
static char            g_pending_attach[512];
static volatile int    g_pending_reset      = 0;
static char            g_pending_save_state[512];
static char            g_pending_load_state[512];

/* Path of the disk image most recently placed in drive 8.
 * After machine_read_snapshot the virtual device loses track of the attached
 * D64 and falls back to host-filesystem mode.  Re-attaching this path
 * immediately after a successful snapshot load restores D64 access. */
static char g_current_disk_path[512] = {0};

/* =========================================================================
 * VICE integration (compiled only when libvice.a is linked in)
 * ========================================================================= */
#ifdef HAVE_VICE_SRC
#include "vice.h"
#include "main.h"
#include "machine.h"
#include "maincpu.h"

/* Provided by the linker --wrap symbols */
extern void __real_maincpu_mainloop(void);
extern int  __real_init_main(void);
extern int  __real_machine_init(void);
extern int  __real_console_init(void);
#include "videoarch.h"
#include "video.h"
#include "palette.h"
#include "keyboard.h"
#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
 *   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()
 * so VICE can select it via -sounddev android.
 * ========================================================================= */
#include "sound.h"

/* Mute flag — 0 = silent (default), 1 = audio passes through. */
static volatile int g_sound_enabled = 0;

/* Lock-free-ish ring buffer (mutex-protected).
 * AUDIO_RING_SAMPLES must be a power of 2 for the masking trick.
 * 32768 × 2 bytes = 64 KB, about 0.34 s at 48 kHz mono. */
#define AUDIO_RING_SAMPLES (1u << 15)
static int16_t          g_audio_ring[AUDIO_RING_SAMPLES];
static unsigned int     g_ring_write = 0;
static unsigned int     g_ring_read  = 0;
static pthread_mutex_t  g_ring_mutex = PTHREAD_MUTEX_INITIALIZER;

/* OpenSL ES state */
static SLObjectItf g_sl_engine_obj  = NULL;
static SLEngineItf g_sl_engine      = NULL;
static SLObjectItf g_sl_out_mix_obj = NULL;
static SLObjectItf g_sl_player_obj  = NULL;
static SLPlayItf   g_sl_play        = NULL;
static SLAndroidSimpleBufferQueueItf g_sl_bq = NULL;

/* Four output buffers, each 2048 int16_t samples (~42 ms at 48 kHz) */
#define AUDIO_N_BUFS    4
#define AUDIO_BUF_SAMPS 2048
static int16_t g_sl_bufs[AUDIO_N_BUFS][AUDIO_BUF_SAMPS];
static int     g_sl_next_buf = 0;

static unsigned int ring_avail_locked(void) {
    return (g_ring_write - g_ring_read) & (AUDIO_RING_SAMPLES - 1);
}

/* OpenSL ES callback — called when a buffer finishes playing. */
static void sl_bufq_callback(SLAndroidSimpleBufferQueueItf bq, void *ctx) {
    (void)ctx;
    int buf = g_sl_next_buf;
    g_sl_next_buf = (g_sl_next_buf + 1) % AUDIO_N_BUFS;

    pthread_mutex_lock(&g_ring_mutex);
    unsigned int avail = ring_avail_locked();
    unsigned int fill  = (avail > AUDIO_BUF_SAMPS) ? AUDIO_BUF_SAMPS : avail;
    for (unsigned int i = 0; i < fill; i++)
        g_sl_bufs[buf][i] = g_audio_ring[(g_ring_read + i) & (AUDIO_RING_SAMPLES - 1)];
    g_ring_read = (g_ring_read + fill) & (AUDIO_RING_SAMPLES - 1);
    pthread_mutex_unlock(&g_ring_mutex);

    /* Silence for underrun */
    for (unsigned int i = fill; i < AUDIO_BUF_SAMPS; i++)
        g_sl_bufs[buf][i] = 0;

    (*bq)->Enqueue(bq, g_sl_bufs[buf], AUDIO_BUF_SAMPS * sizeof(int16_t));
}

static int android_sound_init(const char *param, int *speed,
                               int *fragsize, int *fragnr, int *channels) {
    (void)param;
    if (*channels > 2) *channels = 2;
    if (*channels < 1) *channels = 1;

    SLresult r;
    r = slCreateEngine(&g_sl_engine_obj, 0, NULL, 0, NULL, NULL);
    if (r != SL_RESULT_SUCCESS) { LOGE("slCreateEngine: %d", (int)r); return -1; }
    (*g_sl_engine_obj)->Realize(g_sl_engine_obj, SL_BOOLEAN_FALSE);
    (*g_sl_engine_obj)->GetInterface(g_sl_engine_obj, SL_IID_ENGINE, &g_sl_engine);

    (*g_sl_engine)->CreateOutputMix(g_sl_engine, &g_sl_out_mix_obj, 0, NULL, NULL);
    (*g_sl_out_mix_obj)->Realize(g_sl_out_mix_obj, SL_BOOLEAN_FALSE);

    SLDataLocator_AndroidSimpleBufferQueue loc_bq = {
        SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, AUDIO_N_BUFS };
    SLDataFormat_PCM fmt = {
        SL_DATAFORMAT_PCM,
        (SLuint32)*channels,
        (SLuint32)*speed * 1000,  /* OpenSL ES uses milliHz */
        SL_PCMSAMPLEFORMAT_FIXED_16, SL_PCMSAMPLEFORMAT_FIXED_16,
        (*channels == 2) ? (SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT)
                         : SL_SPEAKER_FRONT_CENTER,
        SL_BYTEORDER_LITTLEENDIAN };
    SLDataSource src = { &loc_bq, &fmt };
    SLDataLocator_OutputMix loc_out = { SL_DATALOCATOR_OUTPUTMIX, g_sl_out_mix_obj };
    SLDataSink   sink = { &loc_out, NULL };

    const SLInterfaceID iid[] = { SL_IID_ANDROIDSIMPLEBUFFERQUEUE };
    const SLboolean     req[] = { SL_BOOLEAN_TRUE };
    r = (*g_sl_engine)->CreateAudioPlayer(g_sl_engine, &g_sl_player_obj,
                                           &src, &sink, 1, iid, req);
    if (r != SL_RESULT_SUCCESS) { LOGE("CreateAudioPlayer: %d", (int)r); return -1; }
    (*g_sl_player_obj)->Realize(g_sl_player_obj, SL_BOOLEAN_FALSE);
    (*g_sl_player_obj)->GetInterface(g_sl_player_obj, SL_IID_PLAY, &g_sl_play);
    (*g_sl_player_obj)->GetInterface(g_sl_player_obj,
                                      SL_IID_ANDROIDSIMPLEBUFFERQUEUE, &g_sl_bq);
    (*g_sl_bq)->RegisterCallback(g_sl_bq, sl_bufq_callback, NULL);
    (*g_sl_play)->SetPlayState(g_sl_play, SL_PLAYSTATE_PLAYING);

    /* Prime the callback loop with AUDIO_N_BUFS silent buffers */
    for (int i = 0; i < AUDIO_N_BUFS; i++) {
        memset(g_sl_bufs[i], 0, sizeof(g_sl_bufs[i]));
        (*g_sl_bq)->Enqueue(g_sl_bq, g_sl_bufs[i], AUDIO_BUF_SAMPS * sizeof(int16_t));
    }
    g_sl_next_buf = 0;
    return 0;
}

static int android_sound_write(int16_t *pbuf, size_t nr) {
    if (!g_sound_enabled) return 0;
    pthread_mutex_lock(&g_ring_mutex);
    unsigned int free_space = (AUDIO_RING_SAMPLES - 1) - ring_avail_locked();
    unsigned int to_write   = ((unsigned int)nr < free_space) ? (unsigned int)nr : free_space;
    for (unsigned int i = 0; i < to_write; i++) {
        g_audio_ring[g_ring_write] = pbuf[i];
        g_ring_write = (g_ring_write + 1) & (AUDIO_RING_SAMPLES - 1);
    }
    pthread_mutex_unlock(&g_ring_mutex);
    return 0;
}

static int android_sound_bufferspace(void) {
    pthread_mutex_lock(&g_ring_mutex);
    int free_space = (int)((AUDIO_RING_SAMPLES - 1) - ring_avail_locked());
    pthread_mutex_unlock(&g_ring_mutex);
    return free_space;
}

static void android_sound_close(void) {
    if (g_sl_play)
        (*g_sl_play)->SetPlayState(g_sl_play, SL_PLAYSTATE_STOPPED);
    if (g_sl_player_obj) {
        (*g_sl_player_obj)->Destroy(g_sl_player_obj);
        g_sl_player_obj = NULL; g_sl_play = NULL; g_sl_bq = NULL;
    }
    if (g_sl_out_mix_obj) {
        (*g_sl_out_mix_obj)->Destroy(g_sl_out_mix_obj);
        g_sl_out_mix_obj = NULL;
    }
    if (g_sl_engine_obj) {
        (*g_sl_engine_obj)->Destroy(g_sl_engine_obj);
        g_sl_engine_obj = NULL; g_sl_engine = NULL;
    }
}

static const sound_device_t g_android_sound_device = {
    "android",
    android_sound_init,
    android_sound_write,
    NULL,   /* dump */
    NULL,   /* flush */
    android_sound_bufferspace,
    android_sound_close,
    NULL,   /* suspend */
    NULL,   /* resume */
    1,      /* need_attenuation */
    2,      /* max_channels */
    false   /* is_timing_source */
};

/* Stubs for symbols that live in arch-specific or excluded files. */
void main_exit(void) {}

/* event_snapshot_write/read_module: VICE's event-recording system allocates its
 * history buffer (start_snapshot.list.base) only when recording is explicitly
 * started via event_record_start().  In our headless build recording is never
 * started, so the buffer stays NULL and the real implementations crash with a
 * null-pointer dereference when called from machine_write_snapshot.
 * Replace them with no-ops: snapshots work fine without event playback data. */
struct snapshot_s;
int __wrap_event_snapshot_write_module(struct snapshot_s *s, int event_mode) {
    (void)s; (void)event_mode; return 0;
}
int __wrap_event_snapshot_read_module(struct snapshot_s *s) {
    (void)s; return 0;
}

/* --wrap=machine_trigger_reset / machine_reset / maincpu_reset: required by the
 * linker --wrap flags in CMakeLists.txt; kept as simple pass-throughs so the
 * symbols exist.  Previously these suppressed resets during snapshot load, but
 * the trap-based snapshot load (see load_state_trap below) makes suppression
 * unnecessary — snapshot load now runs inside DO_INTERRUPT(IK_TRAP) which
 * provides the EXPORT/IMPORT register sync that makes the CPU resume from the
 * correct saved PC without any post-load machine reset. */
void __real_machine_trigger_reset(unsigned int reset_mode);
void __wrap_machine_trigger_reset(unsigned int reset_mode) {
    __real_machine_trigger_reset(reset_mode);
}

void __real_machine_reset(void);
void __wrap_machine_reset(void) {
    __real_machine_reset();
}

void __real_maincpu_reset(void);
void __wrap_maincpu_reset(void) {
    __real_maincpu_reset();
}

/* Snapshot load via VICE CPU trap.
 *
 * machine_read_snapshot() restores CPU registers into maincpu_regs (a global
 * struct), but maincpu_mainloop keeps its own stack-local copies (reg_pc,
 * reg_a, etc.).  If we call machine_read_snapshot() directly from
 * video_canvas_refresh(), the loop never re-imports maincpu_regs, so the CPU
 * continues from the *old* reg_pc in the *new* (restored) memory — wrong.
 *
 * The fix: schedule a VICE CPU trap.  DO_INTERRUPT(IK_TRAP) in 6510core.c
 * calls EXPORT_REGISTERS() before the trap function and IMPORT_REGISTERS()
 * after.  machine_read_snapshot() inside the trap overwrites maincpu_regs
 * with the saved state; IMPORT_REGISTERS() then propagates those values
 * (including the saved PC) back into the CPU loop's stack locals.  Result:
 * the game resumes from exactly the saved execution point. */
/* Both save and load run inside a CPU trap so DO_INTERRUPT(IK_TRAP) calls
 * EXPORT_REGISTERS() before the trap function and IMPORT_REGISTERS() after.
 *
 * For save: EXPORT_REGISTERS() syncs the CPU loop's stack-local reg_pc into
 * maincpu_regs before machine_write_snapshot reads it.  Without the trap,
 * maincpu_regs.pc is stale (last synced at the previous DO_INTERRUPT), so the
 * snapshot would record the wrong PC and restore would resume at the wrong
 * address — input polling code never reached, buttons appear dead.
 *
 * For load: machine_read_snapshot overwrites maincpu_regs with the saved state;
 * IMPORT_REGISTERS() then propagates the saved PC back into reg_pc so the CPU
 * resumes from the correct saved execution point. */
static char g_save_trap_path[512] = {0};
static char g_load_trap_path[512] = {0};

static void save_state_trap(uint16_t address, void *data) {
    (void)address;
    int r = machine_write_snapshot((const char *)data, 0, 0, 0);
    LOGI("machine_write_snapshot → %d", r);
}

static void load_state_trap(uint16_t address, void *data) {
    (void)address;
    int r = machine_read_snapshot((const char *)data, 0);
    LOGI("machine_read_snapshot → %d", r);
}

/* All functions below replace arch/headless/video.c (excluded from libvice.a). */

int  video_arch_get_active_chip(void)              { return 0; /* VIDEO_CHIP_VICII */ }
void video_arch_canvas_init(video_canvas_t *c)     { (void)c; }
int  video_arch_cmdline_options_init(void)          { return 0; }
int  video_arch_resources_init(void)                { return 0; }
void video_arch_resources_shutdown(void)            {}
/* Return 1 so VICE uses visible_width/visible_height for canvas_width/canvas_height.
 * With return 0, VICE would use canvas_physical_width/scalex but physical is never
 * initialised, giving permanent 0x0 frames. */
char video_canvas_can_resize(video_canvas_t *c)    { (void)c; return 1; }
void video_canvas_destroy(video_canvas_t *c)       { (void)c; }
void video_canvas_resize(video_canvas_t *c, char r){ (void)c; (void)r; }
int  video_init(void)                               { return 0; }
void video_shutdown(void)                           {}

int video_canvas_set_palette(video_canvas_t *canvas, struct palette_s *palette) {
    canvas->palette = palette;
    if (!palette || !canvas->videoconfig) return 0;

    /* Force 1×1 pixel rendering.  VICE chip caps set rendermode=2 (PAL_NTSC_2X2)
     * by default for VICII, which doubles each pixel and row and only renders the
     * left half of the screen.  Override to 1 (PAL_NTSC_1X1) for correct
     * 320×200 output into our fixed-size framebuffer. */
    canvas->videoconfig->rendermode = VIDEO_RENDER_PAL_NTSC_1X1;

    /* Force no CRT filter.  VIDEO_FILTER_CRT (the default) routes to
     * render_32_1x1_pal which uses YUV lookup tables built by VICE's PAL color
     * science.  Those tables stay zero in the headless arch (no GPU/SDL setup),
     * so every output pixel is 0x00000000.  VIDEO_FILTER_NONE routes instead to
     * render_32_1x1_04, the simple renderer that reads from physical_colors[]
     * which we correctly populate below via video_render_setphysicalcolor. */
    canvas->videoconfig->filter = VIDEO_FILTER_NONE;

    /* Populate the physical color table so the render pipeline writes real pixels.
     * VICE never calls video_render_setphysicalcolor in the headless arch.
     * Bitmap.copyPixelsFromBuffer reads raw bytes as [R,G,B,A] per pixel.
     * On little-endian ARM a uint32_t 0xAABBGGRR has bytes [R,G,B,A] in memory,
     * so we pack the color as ABGR (not ARGB) to get the correct byte order. */
    for (unsigned int i = 0; i < palette->num_entries; i++) {
        uint32_t col = 0xFF000000u
                       | ((uint32_t)palette->entries[i].blue  << 16)
                       | ((uint32_t)palette->entries[i].green <<  8)
                       |  (uint32_t)palette->entries[i].red;
        video_render_setphysicalcolor(canvas->videoconfig, (int)i, col, 32);
    }
    return 0;
}

/* Full PAL raster dimensions — VICE allocates draw_buffer_line_size = *width
 * bytes per row in the internal draw buffer.  If we return 320 here, reading
 * from xs=136 (PAL left edge of the visible area) wraps to the next row and
 * scrambles the display.  Returning the full PAL frame size (504×312) gives
 * VICE enough room; video_canvas_render() writes into our 320×200 g_framebuf
 * safely because the destination pitch is supplied separately as FRAME_W*4. */
#define PAL_W 504
#define PAL_H 312

video_canvas_t *video_canvas_create(video_canvas_t *canvas,
                                    unsigned int *width, unsigned int *height,
                                    int mapped) {
    (void)mapped;
    canvas->created = 1;
    *width  = PAL_W;
    *height = PAL_H;
    return canvas;
}

/* Called by VICE each time a screen region needs updating.
 * The draw_buffer holds 8-bit palette indices (pitch = width in bytes).
 * video_canvas_render() converts them to 32bpp ARGB using the physical color
 * table we populated in video_canvas_set_palette(). */
void video_canvas_refresh(video_canvas_t *canvas,
                          unsigned int xs, unsigned int ys,
                          unsigned int xi, unsigned int yi,
                          unsigned int w,  unsigned int h) {
    /* Process any pending operations on the VICE thread. */

    /* Hard reset: eject disk and return to BASIC prompt. */
    if (g_pending_reset) {
        g_pending_reset = 0;
        pthread_mutex_lock(&g_pending_lock);
        g_pending_disk[0]   = '\0';
        g_pending_attach[0] = '\0';
        pthread_mutex_unlock(&g_pending_lock);
        file_system_detach_disk(8, 0);
        machine_trigger_reset(MACHINE_RESET_MODE_POWER_CYCLE);
        g_io_frames = g_load_frames = g_attach_frames = 0;
    }

    pthread_mutex_lock(&g_pending_lock);
    char autostart_path[512] = {0};
    char attach_path[512]    = {0};
    if (g_pending_disk[0]   != '\0') { strncpy(autostart_path, g_pending_disk,   sizeof(autostart_path)-1); g_pending_disk[0]   = '\0'; }
    if (g_pending_attach[0] != '\0') { strncpy(attach_path,    g_pending_attach, sizeof(attach_path)-1);    g_pending_attach[0] = '\0'; }
    pthread_mutex_unlock(&g_pending_lock);
    if (autostart_path[0] != '\0') {
        autostart_disk(8, 0, autostart_path, NULL, 0, AUTOSTART_MODE_RUN);
        strncpy(g_current_disk_path, autostart_path, sizeof(g_current_disk_path) - 1);
    }
    if (attach_path[0] != '\0') {
        int r = file_system_attach_disk(8, 0, attach_path);
        if (r == 0) {
            strncpy(g_current_disk_path, attach_path, sizeof(g_current_disk_path) - 1);
            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 */
        }
    }

    /* Snapshot save / load. */
    pthread_mutex_lock(&g_pending_lock);
    char save_state_path[512] = {0};
    char load_state_path[512] = {0};
    if (g_pending_save_state[0] != '\0') {
        strncpy(save_state_path, g_pending_save_state, sizeof(save_state_path) - 1);
        g_pending_save_state[0] = '\0';
    }
    if (g_pending_load_state[0] != '\0') {
        strncpy(load_state_path, g_pending_load_state, sizeof(load_state_path) - 1);
        g_pending_load_state[0] = '\0';
    }
    pthread_mutex_unlock(&g_pending_lock);
    if (save_state_path[0] != '\0') {
        strncpy(g_save_trap_path, save_state_path, sizeof(g_save_trap_path) - 1);
        g_save_trap_path[sizeof(g_save_trap_path) - 1] = '\0';
        interrupt_maincpu_trigger_trap(save_state_trap, g_save_trap_path);
    }
    if (load_state_path[0] != '\0') {
        /* Schedule a VICE CPU trap so machine_read_snapshot runs inside
         * DO_INTERRUPT(IK_TRAP).  That macro calls EXPORT_REGISTERS() before
         * the trap and IMPORT_REGISTERS() after, so the saved PC written into
         * maincpu_regs by machine_read_snapshot is picked up by the CPU loop
         * immediately — the game resumes from the correct saved execution point. */
        strncpy(g_load_trap_path, load_state_path, sizeof(g_load_trap_path) - 1);
        g_load_trap_path[sizeof(g_load_trap_path) - 1] = '\0';
        interrupt_maincpu_trigger_trap(load_state_trap, g_load_trap_path);
    }

    /* Drive LED.
     * VICE's virtual device (vdrive) loads far faster than a real 1541, so
     * autostart_in_progress() is false long before the GDG intro finishes.
     * When autostart ends, start a ~60-second cooldown (g_load_frames) that
     * keeps the LED on throughout the typical intro duration, approximating
     * the real 1541 loading experience. */
    static int prev_autostart = -1;
    int cur_autostart = autostart_in_progress();
    if (prev_autostart == 1 && cur_autostart == 0)
        g_load_frames = 3000; /* ~60 s at 50 fps */
    prev_autostart = cur_autostart;

    if (g_io_frames     > 0) g_io_frames--;
    if (g_load_frames   > 0) g_load_frames--;
    if (g_attach_frames > 0) g_attach_frames--;
    g_drive_led = (cur_autostart || g_io_frames > 0 || g_load_frames > 0 || g_attach_frames > 0) ? 1 : 0;

    /* Clamp source region so it stays within the PAL draw buffer. */
    if (xs >= PAL_W || ys >= PAL_H) return;
    if (xs + w > PAL_W) w = PAL_W - xs;
    if (ys + h > PAL_H) h = PAL_H - ys;
    /* Clamp destination to our 320×200 framebuffer. */
    if (xi >= FRAME_W || yi >= FRAME_H) return;
    if (xi + w > FRAME_W) w = FRAME_W - xi;
    if (yi + h > FRAME_H) h = FRAME_H - yi;
    if (w == 0 || h == 0) return;

    pthread_mutex_lock(&g_lock);
    video_canvas_render(canvas, (uint8_t *)g_framebuf,
                        (int)w, (int)h,
                        (int)xs, (int)ys,
                        (int)xi, (int)yi,
                        FRAME_W * 4);
    pthread_mutex_unlock(&g_lock);

    /* Increment g_frame_count once per C64 video frame.  VICE may call
     * video_canvas_refresh multiple times per logical frame (dirty-rect
     * updates), so guard with a 15 ms minimum interval (~75 % of a 20 ms
     * frame period at 50 fps) — the first call per frame triggers the counter;
     * subsequent calls within the same frame window are skipped. */
    static struct timespec g_last_frame_ts;
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    int64_t now_ns  = (int64_t)ts.tv_sec              * 1000000000LL + ts.tv_nsec;
    int64_t last_ns = (int64_t)g_last_frame_ts.tv_sec * 1000000000LL + g_last_frame_ts.tv_nsec;
    if (now_ns - last_ns >= 15000000LL) {
        g_last_frame_ts = ts;
        g_frame_count++;
    }
}

/* VICE main loop runs in this thread. */
static char  *g_vice_argv[20];
static int    g_vice_argc;

/* Read from fd line-by-line and forward each line to Android logcat.
 * Used to capture VICE stderr output which would otherwise be invisible. */
static void *stderr_reader_thread(void *arg) {
    int fd = *(int *)arg;
    free(arg);
    char line[512];
    int  pos = 0;
    char ch;
    while (read(fd, &ch, 1) == 1) {
        if (ch == '\n' || pos >= (int)sizeof(line) - 1) {
            line[pos] = '\0';
            if (pos > 0) LOGI("VICE: %s", line);
            pos = 0;
        } else {
            line[pos++] = ch;
        }
    }
    close(fd);
    return NULL;
}

static void *vice_thread(void *arg) {
    (void)arg;
    main_program(g_vice_argc, g_vice_argv);
    return NULL;
}

/* archdep_vice_exit normally calls exit() and would kill the whole process.
 * --wrap=archdep_vice_exit redirects all calls here so only the VICE thread
 * terminates instead. */
void __wrap_archdep_vice_exit(int code) {
    (void)code;
    pthread_exit(NULL);
}

void machine_mainloop(void)       { __real_maincpu_mainloop(); }
void __wrap_maincpu_mainloop(void) { __real_maincpu_mainloop(); }
int  __wrap_init_main(void)        { return __real_init_main(); }
int  __wrap_machine_init(void)     { return __real_machine_init(); }
int  __wrap_console_init(void)     { return __real_console_init(); }


#endif /* HAVE_VICE_SRC */

/* =========================================================================
 * JNI entry points
 * ========================================================================= */
#define JNI_FN(ret, name) \
    JNIEXPORT ret JNICALL \
    Java_de_ladkau_schwertundmagieonpebblecompanionapp_C64Engine_##name

JNI_FN(jboolean, initEmulator)(JNIEnv *env, jobject obj, jstring romDir) {
#ifdef HAVE_VICE_SRC
    const char *dir = (*env)->GetStringUTFChars(env, romDir, NULL);

    /* Prepare argv for VICE: x64 -directory <romDir> -headless */
    static char dir_copy[512];
    strncpy(dir_copy, dir, sizeof(dir_copy) - 1);
    (*env)->ReleaseStringUTFChars(env, romDir, dir);

    /* archdep_create_user_cache_dir/config_dir (called from archdep_init) need a
     * writable HOME to create ~/.cache/vice and ~/.config/vice.  On Android,
     * HOME is unset or points to a read-only root, so redirect it to romDir. */
    setenv("HOME", dir_copy, 1);

    /* sysfile_load() searches <Directory>/<machine_name>/<rom_name>.
     * We pass -directory <romDir> so VICE searches romDir/C64/.
     * The ROM files the user provides are named "kernal"/"basic"/"chargen";
     * VICE's compiled-in defaults are "kernal-901227-03.bin" etc., so we
     * also pass -kernal/-basic/-chargen to match the actual file names.
     * Android FUSE rejects symlinks, so we copy the files into romDir/C64/. */
    /* Copy C64 ROMs into romDir/C64/ (sysfile_load searches <dir>/<machine>/). */
    char vice_c64_dir[512];
    snprintf(vice_c64_dir, sizeof(vice_c64_dir), "%s/C64", dir_copy);
    mkdir(vice_c64_dir, 0755);
    const char *c64_roms[] = {"kernal", "basic", "chargen", NULL};
    for (int i = 0; c64_roms[i]; i++) {
        char src[512], dst[512];
        snprintf(src, sizeof(src), "%s/%s", dir_copy,     c64_roms[i]);
        snprintf(dst, sizeof(dst), "%s/%s", vice_c64_dir, c64_roms[i]);
        FILE *fsrc = fopen(src, "rb");
        if (!fsrc) continue;
        FILE *fdst = fopen(dst, "wb");
        if (!fdst) { fclose(fsrc); continue; }
        char buf[4096]; size_t n;
        while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
        fclose(fsrc); fclose(fdst);
    }

    /* Copy 1541 drive ROM into romDir/DRIVES/1541 (sysfile_load path for drives).
     * Required for true 1541 emulation so the game's custom fast loader works.
     * User places the file named "1541" alongside kernal/basic/chargen. */
    char vice_drives_dir[512];
    snprintf(vice_drives_dir, sizeof(vice_drives_dir), "%s/DRIVES", dir_copy);
    mkdir(vice_drives_dir, 0755);
    {
        char src[512], dst[512];
        snprintf(src, sizeof(src), "%s/1541",  dir_copy);
        snprintf(dst, sizeof(dst), "%s/DRIVES/1541", dir_copy);
        FILE *fsrc = fopen(src, "rb");
        if (fsrc) {
            FILE *fdst = fopen(dst, "wb");
            if (fdst) {
                char buf[4096]; size_t n;
                while ((n = fread(buf, 1, sizeof(buf), fsrc)) > 0) fwrite(buf, 1, n, fdst);
                fclose(fdst);
            }
            fclose(fsrc);
        }
    }

    static char dir_arg[512];
    snprintf(dir_arg, sizeof(dir_arg), "%s", dir_copy);

    /* -VICIIborders none  → 320×200 canvas, no borders.
     * -dos1541 1541       → drive ROM filename (overrides VICE's long default).
     * -iecdevice8         → enable IEC device emulation on unit 8 (sets the ID
     *                       bit in iecbus_device_index).  This is the flag that
     *                       actually routes IEC bus traffic for unit 8 to the
     *                       virtual device layer.  Without it the index resolves
     *                       to IECBUS_DEVICE_NONE → "DEVICE NOT PRESENT".
     * -virtualdev8        → enable KERNAL trap-based emulation on unit 8 (VD
     *                       bit).  Together with -iecdevice8 this gives index 3
     *                       → IECBUS_DEVICE_IECDEVICE and fast KERNAL traps.
     * -sounddev android   → select our custom OpenSL ES driver registered above
     *                       instead of VICE's built-in dummy (silent) driver. */
    g_vice_argc     = 17;
    g_vice_argv[0]  = "x64";
    g_vice_argv[1]  = "-directory";
    g_vice_argv[2]  = dir_arg;
    g_vice_argv[3]  = "-kernal";
    g_vice_argv[4]  = "kernal";
    g_vice_argv[5]  = "-basic";
    g_vice_argv[6]  = "basic";
    g_vice_argv[7]  = "-chargen";
    g_vice_argv[8]  = "chargen";
    g_vice_argv[9]  = "-VICIIborders";
    g_vice_argv[10] = "none";
    g_vice_argv[11] = "-dos1541";
    g_vice_argv[12] = "1541";
    g_vice_argv[13] = "-iecdevice8";
    g_vice_argv[14] = "-virtualdev8";
    g_vice_argv[15] = "-sounddev";
    g_vice_argv[16] = "android";
    g_vice_argv[17] = NULL;

    /* Redirect stdout and stderr to Android logcat.
     * VICE's archdep_open_default_log_file() detects a FIFO on stdout and
     * writes all log output there instead of opening a log file, so piping
     * stdout captures trap installation messages, autostart progress, etc.
     * Stderr captures VICE command-line parse errors. */
    int stdout_fds[2], stderr_fds[2];
    if (pipe(stdout_fds) == 0) {
        dup2(stdout_fds[1], STDOUT_FILENO);
        close(stdout_fds[1]);
        int *rfd = malloc(sizeof(int));
        *rfd = stdout_fds[0];
        pthread_t stdout_tid;
        pthread_create(&stdout_tid, NULL, stderr_reader_thread, rfd);
        pthread_detach(stdout_tid);
    }
    if (pipe(stderr_fds) == 0) {
        dup2(stderr_fds[1], STDERR_FILENO);
        close(stderr_fds[1]);
        int *rfd = malloc(sizeof(int));
        *rfd = stderr_fds[0];
        pthread_t stderr_tid;
        pthread_create(&stderr_tid, NULL, stderr_reader_thread, rfd);
        pthread_detach(stderr_tid);
    }

    /* Register the android sound driver before VICE's sound_open() runs.
     * pthread_create provides a memory barrier so the VICE thread sees our write. */
    sound_register_device(&g_android_sound_device);

    pthread_t tid;
    int rc = pthread_create(&tid, NULL, vice_thread, NULL);
    pthread_detach(tid);
    g_ready = (rc == 0);
    return (jboolean)g_ready;
#else
    (void)env; (void)obj; (void)romDir;
    /* Fill with C64-blue placeholder so the UI works without VICE. */
    for (int i = 0; i < FRAME_W * FRAME_H; i++)
        g_framebuf[i] = 0xFF6464C0u;
    g_ready = 1;
    return JNI_TRUE;
#endif
}

JNI_FN(jboolean, loadDisk)(JNIEnv *env, jobject obj, jstring path) {
    (void)obj;
#ifdef HAVE_VICE_SRC
    const char *p = (*env)->GetStringUTFChars(env, path, NULL);
    /* Queue the disk path for autostart on the VICE thread.
     * autostart_disk must not be called cross-thread — video_canvas_refresh
     * picks this up on the next frame and calls it from the VICE thread. */
    pthread_mutex_lock(&g_pending_lock);
    strncpy(g_pending_disk, p, sizeof(g_pending_disk) - 1);
    g_pending_disk[sizeof(g_pending_disk) - 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, 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, resetMachine)(JNIEnv *env, jobject obj) {
    (void)env; (void)obj;
#ifdef HAVE_VICE_SRC
    g_pending_reset = 1;
    return JNI_TRUE;
#else
    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;
}

/* 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;
}

JNI_FN(void, getVideoBuffer)(JNIEnv *env, jobject obj, jobject byteBuffer) {
    (void)obj;
    uint8_t *dst = (uint8_t *)(*env)->GetDirectBufferAddress(env, byteBuffer);
    if (!dst) return;
    pthread_mutex_lock(&g_lock);
    memcpy(dst, g_framebuf, FRAME_W * FRAME_H * 4);
    pthread_mutex_unlock(&g_lock);
}

JNI_FN(void, injectKey)(JNIEnv *env, jobject obj, jint keyCode, jboolean pressed) {
    (void)env; (void)obj;
#ifdef HAVE_VICE_SRC
    int row = (keyCode >> 8) & 0xFF;
    int col =  keyCode       & 0xFF;
    keyboard_set_keyarr(row, col, pressed ? 1 : 0);
#else
    (void)keyCode; (void)pressed;
#endif
}

JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) {
    (void)env; (void)obj;
    g_sound_enabled = enabled ? 1 : 0;
}

JNI_FN(jboolean, saveState)(JNIEnv *env, jobject obj, jstring jpath) {
    (void)obj;
#ifdef HAVE_VICE_SRC
    const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
    pthread_mutex_lock(&g_pending_lock);
    strncpy(g_pending_save_state, p, sizeof(g_pending_save_state) - 1);
    g_pending_save_state[sizeof(g_pending_save_state) - 1] = '\0';
    pthread_mutex_unlock(&g_pending_lock);
    (*env)->ReleaseStringUTFChars(env, jpath, p);
    return JNI_TRUE;
#else
    (void)env; (void)jpath;
    return JNI_FALSE;
#endif
}

JNI_FN(jboolean, loadState)(JNIEnv *env, jobject obj, jstring jpath) {
    (void)obj;
#ifdef HAVE_VICE_SRC
    const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
    pthread_mutex_lock(&g_pending_lock);
    strncpy(g_pending_load_state, p, sizeof(g_pending_load_state) - 1);
    g_pending_load_state[sizeof(g_pending_load_state) - 1] = '\0';
    pthread_mutex_unlock(&g_pending_lock);
    (*env)->ReleaseStringUTFChars(env, jpath, p);
    return JNI_TRUE;
#else
    (void)env; (void)jpath;
    return JNI_FALSE;
#endif
}
