diff --git a/engine/symbian/src/lib.rs b/engine/symbian/src/lib.rs index a2e9f4afe..9c183dcab 100644 --- a/engine/symbian/src/lib.rs +++ b/engine/symbian/src/lib.rs @@ -9,7 +9,8 @@ //! QGLWidget owns the graphics context and calls the GLES2 entry points only //! while it is current. The software capture entry points return tightly //! packed, top-left-origin ARGB32 pixels; those pointers remain valid until -//! the next capture, viewport change, init, or shutdown call. +//! the next capture, viewport change, init, or shutdown call. Framebuffer-only +//! hosts may instead render directly into host-owned RGB565 storage. #![cfg_attr(any(target_os = "none", feature = "bare-platform"), no_std)] #![cfg_attr( @@ -159,6 +160,7 @@ static mut DAMAGE_BOUNDS: [i32; 4] = [0, 0, 0, 0]; static mut FRAMEBUFFER_WIDTH: u32 = 0; static mut FRAMEBUFFER_HEIGHT: u32 = 0; static mut FRAMEBUFFER_STRIDE: u32 = 0; +static mut FRAMEBUFFER_LENGTH: usize = 0; /// Stock cores have no application-specific native surface. A custom static /// library depends on this crate with default features disabled and exports @@ -202,6 +204,7 @@ fn clear_framebuffer() { FRAMEBUFFER_WIDTH = 0; FRAMEBUFFER_HEIGHT = 0; FRAMEBUFFER_STRIDE = 0; + FRAMEBUFFER_LENGTH = 0; } } @@ -667,11 +670,12 @@ fn framebuffer_geometry(instance: &Ui, scale: u32) -> Option<(usize, usize, usiz Some((width, height, byte_len)) } -fn remember_framebuffer_geometry(width: usize, height: usize) { +fn remember_framebuffer_geometry(width: usize, height: usize, bytes_per_pixel: usize) { unsafe { FRAMEBUFFER_WIDTH = width as u32; FRAMEBUFFER_HEIGHT = height as u32; - FRAMEBUFFER_STRIDE = (width * 4) as u32; + FRAMEBUFFER_STRIDE = (width * bytes_per_pixel) as u32; + FRAMEBUFFER_LENGTH = width * height * bytes_per_pixel; } } @@ -737,11 +741,66 @@ fn render_at_scale(scale: u32, incremental: bool) -> *const u8 { DAMAGE_BOUNDS = [0, 0, width as i32, height as i32]; } - remember_framebuffer_geometry(width, height); + remember_framebuffer_geometry(width, height, 4); FRAMEBUFFER.as_ptr() } } +/// Render incrementally into a caller-owned native RGB565 framebuffer. +/// +/// Framebuffer-only hosts use this path to avoid allocating an intermediate +/// ARGB surface and converting every pixel after each render. The buffer is +/// tightly packed at the logical viewport size and remains owned by the host. +#[no_mangle] +pub extern "C" fn ui_render_rgb565_incremental(framebuffer: *mut u16, pixel_count: usize) -> i32 { + if framebuffer.is_null() { + return 0; + } + let instance = ui(); + let Some((width, height, byte_len)) = framebuffer_geometry(instance, 1) else { + return 0; + }; + let expected_pixels = byte_len / 4; + if pixel_count != expected_pixels { + return 0; + } + let draw_list: *const pocketjs_core::DrawList = instance.draw(); + let instance_ref: &Ui = unsafe { &*(instance as *const Ui) }; + let output = unsafe { core::slice::from_raw_parts_mut(framebuffer, pixel_count) }; + + unsafe { + DAMAGE_ATTEMPTS = DAMAGE_ATTEMPTS.wrapping_add(1); + match raster::render_scaled_rgb565_incremental( + instance_ref, + &(*draw_list).words, + output, + 1, + &mut DAMAGE_TRACKER, + DamagePolicy::default(), + ) { + Ok(plan) => { + let bounds = plan.bounds(); + DAMAGE_REGIONS = plan.region_count() as u32; + DAMAGE_PIXELS = plan.area(); + DAMAGE_BOUNDS = [bounds.x0, bounds.y0, bounds.x1, bounds.y1]; + if plan.is_full_redraw() { + DAMAGE_FULL_REDRAWS = DAMAGE_FULL_REDRAWS.wrapping_add(1); + } + } + Err(_) => { + DAMAGE_FAILURES = DAMAGE_FAILURES.wrapping_add(1); + DAMAGE_REGIONS = 0; + DAMAGE_PIXELS = pixel_count as u64; + DAMAGE_BOUNDS = [0, 0, width as i32, height as i32]; + raster::render_scaled_rgb565(instance_ref, &(*draw_list).words, output, 1); + DAMAGE_TRACKER.invalidate(); + } + } + remember_framebuffer_geometry(width, height, 2); + } + 1 +} + /// Incremental-raster statistics, so a host can tell a working damage plan /// from a silent per-frame fallback. Counts are cumulative; the region, /// pixel and bounds values describe the most recent frame. @@ -829,7 +888,7 @@ pub extern "C" fn ui_framebuffer_stride() -> u32 { #[no_mangle] pub extern "C" fn ui_framebuffer_len() -> usize { - unsafe { FRAMEBUFFER.len() } + unsafe { FRAMEBUFFER_LENGTH } } #[cfg(test)] @@ -867,6 +926,15 @@ mod tests { assert_eq!(ui_framebuffer_len(), 8); let pixels = unsafe { core::slice::from_raw_parts(framebuffer, 8) }; assert_eq!(pixels, &[0x11, 0x22, 0x33, 0xff, 0x11, 0x22, 0x33, 0xff]); + + let mut rgb565 = [0u16; 2]; + assert_eq!( + ui_render_rgb565_incremental(rgb565.as_mut_ptr(), rgb565.len()), + 1 + ); + assert_eq!(rgb565, [raster::pack_rgb565(0x33, 0x22, 0x11); 2]); + assert_eq!(ui_framebuffer_stride(), 4); + assert_eq!(ui_framebuffer_len(), 4); ui_shutdown(); } } diff --git a/framework/src/kinetics-core.ts b/framework/src/kinetics-core.ts index 110ba8042..f611a3397 100644 --- a/framework/src/kinetics-core.ts +++ b/framework/src/kinetics-core.ts @@ -4,11 +4,9 @@ // tracking finger-follow (gesture pan feeds drag deltas; out-of-bounds // travel is rubber-banded with the classic iOS curve) // fling exponential decay from the release velocity -// spring edge bounce-back — semi-implicit Euler with the engine's -// Spring constants (K=170, C=26), CARRYING the incoming velocity -// (a fling that crosses an edge keeps its momentum into the -// rubber band; this is the one place "spring with initial -// velocity" is needed, and it lives here, not in the core) +// spring edge bounce-back or retargetable programmatic follow — +// semi-implicit Euler with the engine's Spring constants +// (K=170, C=26), carrying incoming velocity across retargets // chase per-frame ease toward a target — byte-for-byte the apps/im // pump (0.3 of the remaining distance, snap under 0.6 px); the // d-pad / stick-to-bottom mode @@ -76,14 +74,21 @@ export interface Scroller { nudge(delta: number): void; /** Chase an absolute target (focus-follow, stick-to-bottom). */ chaseTo(to: number): void; + /** Spring toward an absolute target. Repeated calls preserve velocity. + * `overshootPx` adds a bounded approach point beyond the final target; + * stiffness/damping default to the edge-spring constants. */ + springTo(to: number, opts?: { + overshootPx?: number; + stiffness?: number; + damping?: number; + }): void; /** Shift offset AND every in-flight anchor by delta after a prepend, so * backfill never moves what the user is looking at (the im rebase). */ rebase(delta: number): void; - /** The position the scroller is heading to: the chase/tween target when - * one is in flight, the current offset otherwise. */ + /** The position the scroller is heading to: the chase/tween/spring final + * target when one is in flight, the current offset otherwise. */ intent(): number; - /** At the end of the range, judged on INTENT: the chase/tween target when - * one is in flight, the position otherwise (the im at-bottom rule). */ + /** At the end of the range, judged on INTENT rather than transient motion. */ isAtEnd(slackPx?: number): boolean; /** Rest position a fling from `v` would reach (for snap functions). */ projectFling(v: number): number; @@ -147,7 +152,13 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S let v = 0; // px per virtual second (fling/spring) let dragPos = 0; // unrubbered drag-space position while tracking let target = 0; // chase target - let springBound = 0; // the edge a spring is heading to + let springBound = 0; // current spring approach/rebound point + let springFinal = 0; // clamped public intent + let springDirection = 0; // approach direction, -1 / 0 / 1 + let springOvershoot = 0; + let springReturning = false; + let springK = SPRING_K; + let springC = SPRING_C; let tweenFrom = 0; let tweenTo = 0; let tweenFrames = 1; @@ -194,6 +205,17 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S return pos + (v0 * TICK_DT * decay) / (1 - decay); } + function startEdgeSpring(bound: number): void { + springBound = bound; + springFinal = bound; + springDirection = 0; + springOvershoot = 0; + springReturning = false; + springK = SPRING_K; + springC = SPRING_C; + state = "spring"; + } + return { offset, velocity: () => v, @@ -218,9 +240,8 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S if (state !== "tracking") return; const m = opts.max(); if (pos < 0 || pos > m) { - springBound = pos < 0 ? 0 : m; v = releaseVelocity; - state = "spring"; + startEdgeSpring(pos < 0 ? 0 : m); return; } if (opts.snap) { @@ -249,7 +270,13 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S }, scrollBy(delta: number, o?: { durMs?: number } | { immediate: true }): void { - const base = state === "tween" ? tweenTo : state === "chase" ? target : pos; + const base = state === "tween" + ? tweenTo + : state === "chase" + ? target + : state === "spring" + ? springFinal + : pos; this.scrollTo(base + delta, o); }, @@ -270,17 +297,56 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S state = "chase"; }, + springTo(to: number, o?: { + overshootPx?: number; + stiffness?: number; + damping?: number; + }): void { + const final = clampRange(to); + const delta = final - pos; + const direction = delta < 0 ? -1 : delta > 0 ? 1 : 0; + const requested = o?.overshootPx ?? 0; + const approach = Number.isFinite(requested) && requested > 0 ? requested : 0; + const requestedK = o?.stiffness; + const requestedC = o?.damping; + + if (state !== "spring" && state !== "fling") v = 0; + springFinal = final; + springDirection = direction; + springOvershoot = direction === 0 ? 0 : approach; + springReturning = false; + springK = requestedK !== undefined && Number.isFinite(requestedK) && requestedK > 0 + ? requestedK + : SPRING_K; + springC = requestedC !== undefined && Number.isFinite(requestedC) && requestedC > 0 + ? requestedC + : SPRING_C; + springBound = final + direction * springOvershoot; + if (springBound === pos && v === 0) { + state = "idle"; + return; + } + state = "spring"; + }, + rebase(delta: number): void { dragPos += delta; target += delta; springBound += delta; + springFinal += delta; tweenFrom += delta; tweenTo += delta; emit(pos + delta); }, intent(): number { - return state === "chase" ? target : state === "tween" ? tweenTo : pos; + return state === "chase" + ? target + : state === "tween" + ? tweenTo + : state === "spring" + ? springFinal + : pos; }, isAtEnd(slackPx = 1): boolean { @@ -334,8 +400,7 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S const m = opts.max(); if (p < 0 || p > m) { // Carry the momentum into the edge spring mid-tick. - springBound = p < 0 ? 0 : m; - state = "spring"; + startEdgeSpring(p < 0 ? 0 : m); continue; } if (v < FLING_MIN_V && v > -FLING_MIN_V) { @@ -343,18 +408,35 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S return; } } else { - const b = clampRange(springBound); - const a = SPRING_K * (b - p) - SPRING_C * v; + const b = springBound; + const a = springK * (b - p) - springC * v; v += a * TICK_DT; p += v * TICK_DT; const dist = b - p; + const crossedApproach = !springReturning && springOvershoot > 0 && ( + springDirection > 0 ? p >= b : p <= b + ); + if (crossedApproach) { + p = b; + v = 0; + springReturning = true; + springBound = springFinal; + continue; + } if ( dist < SPRING_SETTLE_DIST && dist > -SPRING_SETTLE_DIST && v < SPRING_SETTLE_V && v > -SPRING_SETTLE_V ) { - settle(b); + if (!springReturning && springOvershoot > 0) { + p = b; + v = 0; + springReturning = true; + springBound = springFinal; + continue; + } + settle(springFinal); return; } } diff --git a/hosts/iphone2g/pocket_core.h b/hosts/iphone2g/pocket_core.h index ce76cff5b..d70e2fe52 100644 --- a/hosts/iphone2g/pocket_core.h +++ b/hosts/iphone2g/pocket_core.h @@ -58,6 +58,7 @@ int32_t ui_debug_rect_wh(void); void ui_debug_pause(int32_t paused); void ui_debug_step(void); const uint8_t *ui_render_incremental(void); +int32_t ui_render_rgb565_incremental(uint16_t *framebuffer, size_t pixel_count); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); uint32_t ui_framebuffer_stride(void); diff --git a/hosts/iphone2g/pocket_runtime.c b/hosts/iphone2g/pocket_runtime.c index 44990522f..69936a4bf 100644 --- a/hosts/iphone2g/pocket_runtime.c +++ b/hosts/iphone2g/pocket_runtime.c @@ -27,6 +27,10 @@ extern void pocket_host_boot_stage(int stage); #define REPORT_BOOT_STAGE(stage) ((void)(stage)) #endif +#ifndef POCKET_RUNTIME_JS_STACK_SIZE +#define POCKET_RUNTIME_JS_STACK_SIZE (256 * 1024) +#endif + typedef enum { HostCreateNode, HostDestroyNode, @@ -84,15 +88,30 @@ static void set_error(const char *message) { static void take_exception(JSContext *exception_context) { JSValue exception = JS_GetException(exception_context); + JSValue detail = JS_UNDEFINED; size_t length = 0; - const char *message = JS_ToCStringLen2(exception_context, &length, exception, 0); + const char *message = 0; + if (JS_IsObject(exception)) { + detail = JS_GetPropertyStr(exception_context, exception, "message"); + if (!JS_IsException(detail) && !JS_IsUndefined(detail)) { + message = JS_ToCStringLen2(exception_context, &length, detail, 0); + } + } + if (message == 0 && !JS_IsException(detail)) { + message = JS_ToCStringLen2(exception_context, &length, exception, 0); + } if (message != 0) { size_t copy_length = length < sizeof(last_error) - 1 ? length : sizeof(last_error) - 1; memcpy(last_error, message, copy_length); last_error[copy_length] = '\0'; JS_FreeCString(exception_context, message); } else { - set_error("QuickJS exception"); + if (JS_IsNull(exception)) set_error("QuickJS out of memory"); + else if (JS_IsUninitialized(exception)) set_error("QuickJS missing exception"); + else set_error("QuickJS exception"); + } + if (!JS_IsUndefined(detail) && !JS_IsException(detail)) { + JS_FreeValue(exception_context, detail); } JS_FreeValue(exception_context, exception); } @@ -561,7 +580,7 @@ int pocket_runtime_boot( return 0; } REPORT_BOOT_STAGE(4); - JS_SetMaxStackSize(runtime, 256 * 1024); + JS_SetMaxStackSize(runtime, POCKET_RUNTIME_JS_STACK_SIZE); context = JS_NewContext(runtime); if (context == 0) { set_error("QuickJS context allocation failed"); @@ -632,6 +651,7 @@ int pocket_runtime_boot( /* One guest turn (frame call + job drain), then `tick_count` core ticks. */ static int run_frame( uint32_t buttons, + uint32_t analog, const PocketRuntimeContact *contacts, unsigned int contact_count, unsigned int tick_count @@ -680,7 +700,7 @@ static int run_frame( } JSValue arguments[4] = { JS_NewUint32(context, buttons), - JS_NewInt32(context, POCKET_ANALOG_CENTER), + JS_NewUint32(context, analog), touch_array, hit_array, }; @@ -728,12 +748,22 @@ int pocket_runtime_tick(const PocketRuntimeInput *input) { input->touch_y, input->touch_hit ); - return run_frame(input->buttons, &contact, count, 1); + return run_frame(input->buttons, POCKET_ANALOG_CENTER, &contact, count, 1); +} + +int pocket_runtime_tick_analog(uint32_t buttons, uint32_t analog) { + return run_frame(buttons, analog, 0, 0, 1); } int pocket_runtime_tick_contacts(const PocketRuntimeContactsInput *input) { if (input == 0) return 0; - return run_frame(input->buttons, input->contacts, input->contact_count, 1); + return run_frame( + input->buttons, + POCKET_ANALOG_CENTER, + input->contacts, + input->contact_count, + 1 + ); } int pocket_runtime_frame_contacts( @@ -741,7 +771,13 @@ int pocket_runtime_frame_contacts( unsigned int tick_count ) { if (input == 0) return 0; - return run_frame(input->buttons, input->contacts, input->contact_count, tick_count); + return run_frame( + input->buttons, + POCKET_ANALOG_CENTER, + input->contacts, + input->contact_count, + tick_count + ); } int pocket_runtime_frame_ticks( @@ -753,7 +789,7 @@ int pocket_runtime_frame_ticks( ) { PocketRuntimeContact contact; unsigned int count = single_contact(&contact, touch_down, touch_x, touch_y, touch_hit); - return run_frame(0, &contact, count, tick_count); + return run_frame(0, POCKET_ANALOG_CENTER, &contact, count, tick_count); } int pocket_runtime_frame(int touch_down, int touch_x, int touch_y, int touch_hit) { @@ -788,6 +824,11 @@ const uint8_t *pocket_runtime_render(void) { return ui_render_incremental(); } +int pocket_runtime_render_rgb565(uint16_t *framebuffer, size_t pixel_count) { + if (runtime == 0 || context == 0 || runtime_failed) return 0; + return ui_render_rgb565_incremental(framebuffer, pixel_count) != 0; +} + unsigned long pocket_runtime_damage_attempts(void) { if (runtime == 0 || context == 0 || runtime_failed) return 0; return (unsigned long)ui_damage_attempts(); diff --git a/hosts/iphone2g/pocket_runtime.h b/hosts/iphone2g/pocket_runtime.h index ca6bad89a..2c00a8a1d 100644 --- a/hosts/iphone2g/pocket_runtime.h +++ b/hosts/iphone2g/pocket_runtime.h @@ -28,6 +28,8 @@ typedef struct { int touch_hit; } PocketRuntimeInput; int pocket_runtime_tick(const PocketRuntimeInput *input); +/* Button + analog-only entry for hosts with a wheel/stick and no touch. */ +int pocket_runtime_tick_analog(uint32_t buttons, uint32_t analog); /* * Multi-contact frame entry. `id` is the host's contact slot (0-255, stable @@ -76,11 +78,13 @@ int pocket_runtime_hit_test_bounds(float x, float y); const char *pocket_runtime_action_name(void); int pocket_runtime_action_value(void); unsigned long pocket_runtime_action_sequence(void); -const uint8_t *pocket_runtime_render(void); /* * Rendered pixels are opaque top-left BGRA bytes (ARGB32 words). The pointer * remains valid only until the next render, viewport change, or shutdown. */ +const uint8_t *pocket_runtime_render(void); +/* Render directly into a persistent, tightly packed host-owned RGB565 buffer. */ +int pocket_runtime_render_rgb565(uint16_t *framebuffer, size_t pixel_count); /* * Damage statistics for the software raster path, and the most recent plan's diff --git a/hosts/rockbox/README.md b/hosts/rockbox/README.md new file mode 100644 index 000000000..555f727dc --- /dev/null +++ b/hosts/rockbox/README.md @@ -0,0 +1,53 @@ +# PocketJS for Rockbox iPod classic + +This host embeds a PocketJS application in a Rockbox plugin for the 6th/7th +generation iPod classic (`ipod6g`). It targets the native 320x240 RGB565 LCD +and ARM926EJ-S/ARMv5TE CPU. The current profile is development-only and uses +the software rasterizer, QuickJS, and Rockbox's remaining plugin buffer as a +TLSF heap. + +## Controls + +| iPod control | PocketJS input | +| --- | --- | +| Select | Circle / confirm | +| Menu | Triangle / back | +| Left, Right | Left, Right | +| Play/Pause | Start | +| Wheel clockwise, counter-clockwise | Down, Up | +| Hold Menu | Exit plugin | + +## Build + +Rockbox recommends building its ARM cross compiler with `tools/rockboxdev.sh`. +For an existing Rockbox source checkout and toolchain: + +```sh +bun install --frozen-lockfile +bun rockbox bootstrap +ROCKBOX_SOURCE=/path/to/rockbox bun rockbox test +ROCKBOX_SOURCE=/path/to/rockbox bun rockbox build +``` + +The hardware artifact is written to: + +```text +dist/rockbox/pocketjs-ipod6g.rock +``` + +To package a different app, pass its manifest: + +```sh +ROCKBOX_SOURCE=/path/to/rockbox \ + bun rockbox build --manifest=/path/to/app.pocket.json +``` + +Copy the resulting file to `.rockbox/rocks/apps/pocketjs.rock` on the mounted +iPod, eject it cleanly, then launch it from **Plugins > Applications**. + +## Current boundary + +This first host exposes baked text, software rendering, and click-wheel/button +input. Audio, networking, filesystem APIs, and arbitrary logical viewport +scaling are not advertised by the target profile. A successful cross-build or +USB copy does not replace an on-device launch/input test. diff --git a/hosts/rockbox/compat.c b/hosts/rockbox/compat.c new file mode 100644 index 000000000..36ec4dbe0 --- /dev/null +++ b/hosts/rockbox/compat.c @@ -0,0 +1,29 @@ +#include "compat.h" + +#include + +static int pocket_errno; + +int *__errno(void) { return &pocket_errno; } + +int gettimeofday(struct timeval *tv, void *timezone) { + long ticks; + (void)timezone; + if (tv == 0) return -1; + ticks = *rb->current_tick; + tv->tv_sec = ticks / HZ; + tv->tv_usec = (ticks % HZ) * (1000000L / HZ); + return 0; +} + +void abort(void) { + rb->splash(HZ * 3, "PocketJS: native abort"); + exit(PLUGIN_ERROR); + while (true) { } +} + +/* Rust is built with panic=abort. These personality symbols are still named + by ARM exception metadata but must never begin a real unwind in Rockbox. */ +void __aeabi_unwind_cpp_pr0(void) { abort(); } +void __aeabi_unwind_cpp_pr1(void) { abort(); } +void __aeabi_unwind_cpp_pr2(void) { abort(); } diff --git a/hosts/rockbox/compat.h b/hosts/rockbox/compat.h new file mode 100644 index 000000000..005337739 --- /dev/null +++ b/hosts/rockbox/compat.h @@ -0,0 +1,111 @@ +#ifndef POCKETJS_ROCKBOX_COMPAT_H +#define POCKETJS_ROCKBOX_COMPAT_H + +#include "plugin.h" +#include + +/* Rockbox's container_of type-check rejects QuickJS flexible-array members. */ +#undef container_of +#define container_of(ptr, type, member) \ + ((type *)((char *)(ptr) - offsetof(type, member))) + +/* QuickJS and the no_std Rust core share Rockbox's remaining plugin buffer. */ +#define malloc tlsf_malloc +#define calloc tlsf_calloc +#define realloc tlsf_realloc +#define free tlsf_free + +#define memcpy rb->memcpy +#define memmove rb->memmove +#define memset rb->memset +#define memcmp rb->memcmp +#define memchr rb->memchr +#define strlen rb->strlen +#define strcmp rb->strcmp +#define strncmp rb->strncmp +#define strcpy rb->strcpy +#define strncpy rb->strncpy +#define strchr rb->strchr +#define strrchr rb->strrchr +#define strstr rb->strstr +#define strtol rb->strtol +#define strtoul rb->strtoul +#define qsort rb->qsort +#define snprintf rb->snprintf +#define vsnprintf rb->vsnprintf + +/* Rockbox deliberately ships a tiny math.h. The hardware build links + newlib's soft-float libm, so expose the C99 declarations QuickJS needs. */ +double acos(double); +double acosh(double); +double asin(double); +double asinh(double); +double atan(double); +double atan2(double, double); +double atanh(double); +double cbrt(double); +double ceil(double); +double cos(double); +double cosh(double); +double exp(double); +double expm1(double); +double fabs(double); +double floor(double); +double fmax(double, double); +double fmin(double, double); +double fmod(double, double); +double hypot(double, double); +double log(double); +double log1p(double); +double log2(double); +double log10(double); +double pow(double, double); +double round(double); +long int lrint(double); +double sin(double); +double sinh(double); +double sqrt(double); +double tan(double); +double tanh(double); +double trunc(double); + +#ifndef isnan +#define isnan(value) __builtin_isnan(value) +#endif +#ifndef isfinite +#define isfinite(value) __builtin_isfinite(value) +#endif +#ifndef isinf +#define isinf(value) __builtin_isinf(value) +#endif +#ifndef signbit +#define signbit(value) __builtin_signbit(value) +#endif +#ifndef NAN +#define NAN __builtin_nanf("") +#endif +#ifndef INFINITY +#define INFINITY __builtin_inf() +#endif + +void abort(void) __attribute__((noreturn)); +void exit(int status) __attribute__((noreturn)); + +/* Hardware Rockbox deliberately omits stdio streams. QuickJS only uses these + in its optional diagnostics, which stay silent in this plugin. */ +#ifndef SIMULATOR +typedef void FILE; +#define stdout ((FILE *)0) +#define stderr ((FILE *)0) +#define putchar(value) (value) +#define fputc(value, stream) ((void)(stream), (value)) +#define fwrite(ptr, size, count, stream) \ + ((void)(ptr), (void)(size), (void)(stream), (count)) +#endif + +/* Diagnostic-only QuickJS printers are retained but silent on-device. */ +#define printf(...) ((void)0) +#define fprintf(stream, ...) ((void)(stream), 0) +#define puts(value) ((void)(value), 0) + +#endif diff --git a/hosts/rockbox/demo.pocket.json b/hosts/rockbox/demo.pocket.json new file mode 100644 index 000000000..a5af8ccc2 --- /dev/null +++ b/hosts/rockbox/demo.pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.rockbox-ipod-classic", + "name": "pocketjs-rockbox-ipod-classic", + "title": "PocketJS — iPod classic", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.buttons", "text.glyphs.baked"] + } + }, + "app": { + "entry": "hosts/rockbox/demo/main.tsx", + "output": "rockbox-ipod-classic-demo", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [320, 240], + "presentation": "native" + } + } + } +} diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts new file mode 100644 index 000000000..40943a751 --- /dev/null +++ b/hosts/rockbox/demo/contact-motion.ts @@ -0,0 +1,86 @@ +export const CONTACT_ROW_HEIGHT = 30; +export const CONTACT_LIST_HEIGHT = 204; +export const CONTACT_CENTER_ANCHOR_Y = + (CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT) / 2; +export const CONTACT_UP_ANCHOR_Y = 3 * CONTACT_ROW_HEIGHT; +export const CONTACT_DOWN_ANCHOR_Y = + CONTACT_LIST_HEIGHT - 3 * CONTACT_ROW_HEIGHT; +export const CONTACT_SPRING_OVERSHOOT = 12; +export const CONTACT_SPRING_STIFFNESS = 480; +export const CONTACT_SPRING_DAMPING = 44; +export const CONTACT_MAX_OFFSCREEN_ROWS = 0.5; +export const CONTACT_MAX_OFFSCREEN_PX = + CONTACT_ROW_HEIGHT * CONTACT_MAX_OFFSCREEN_ROWS; + +export function wheelMultiplier(burst: number): number { + const gear = Math.min(10, Math.floor(Math.max(0, burst) / 3)); + return 1 << gear; +} + +/** Position an independent selection bar over its logical row while possible, + * then clamp the bar itself so no more than maxOffscreenPx can be clipped at + * either viewport edge. The real row remains free to travel far off-screen. */ +export function contactSelectionY( + selectedIndex: number, + offset: number, + maxOffscreenPx = CONTACT_MAX_OFFSCREEN_PX, +): number { + const rowY = selectedIndex * CONTACT_ROW_HEIGHT - offset; + return Math.max( + -maxOffscreenPx, + Math.min( + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT + maxOffscreenPx, + rowY, + ), + ); +} + +/** Keep the real selected row within the same half-row clip bounds as the + * independent selection bar. A remote wheel target may remain far ahead and + * drive the sprint, but it cannot become the selected contact until the list + * has visually caught up to it. */ +export function contactVisibleIndex( + targetIndex: number, + offset: number, + count: number, + maxOffscreenPx = CONTACT_MAX_OFFSCREEN_PX, +): number { + const first = Math.max( + 0, + Math.ceil((offset - maxOffscreenPx) / CONTACT_ROW_HEIGHT), + ); + const last = Math.min( + count - 1, + Math.floor( + (offset + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT + maxOffscreenPx) / + CONTACT_ROW_HEIGHT, + ), + ); + return Math.max(first, Math.min(last, targetIndex)); +} + +/** Final list offset required to bring a selected row back into the resting + * band. null means the row can move inside the band without moving the list. */ +export function contactScrollTarget( + selectedIndex: number, + currentIntent: number, + maxOffset: number, +): number | null { + const rowTop = selectedIndex * CONTACT_ROW_HEIGHT; + const lastRowTop = maxOffset + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT; + if (rowTop === 0) return currentIntent === 0 ? null : 0; + if (rowTop >= lastRowTop) { + return currentIntent === maxOffset ? null : maxOffset; + } + const screenY = rowTop - currentIntent; + let target: number; + if (screenY > CONTACT_DOWN_ANCHOR_Y) { + target = rowTop - CONTACT_DOWN_ANCHOR_Y; + } else if (screenY < CONTACT_UP_ANCHOR_Y) { + target = rowTop - CONTACT_UP_ANCHOR_Y; + } else { + return null; + } + const clamped = Math.max(0, Math.min(maxOffset, target)); + return clamped === currentIntent ? null : clamped; +} diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx new file mode 100644 index 000000000..239191eff --- /dev/null +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -0,0 +1,326 @@ +import { For, Show, createMemo, createSignal, type Accessor } from "solid-js"; +import { animate } from "@pocketjs/framework/animation"; +import { + Text, + View, + type NodeMirror, +} from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { + createScroller, + type Scroller, +} from "@pocketjs/framework/kinetics"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { + CONTACT_LIST_HEIGHT, + CONTACT_ROW_HEIGHT, + CONTACT_SPRING_DAMPING, + CONTACT_SPRING_OVERSHOOT, + CONTACT_SPRING_STIFFNESS, + contactSelectionY, + contactScrollTarget, + contactVisibleIndex, + wheelMultiplier, +} from "./contact-motion.ts"; + +const CONTACT_COUNT = 10_000; +const CONTACT_WINDOW_ROWS = Math.ceil(CONTACT_LIST_HEIGHT / CONTACT_ROW_HEIGHT) + 2; +const CONTACT_ROW_SLOTS = Array.from( + { length: CONTACT_WINDOW_ROWS }, + (_, index) => index, +); +const WHEEL_ACCEL_RESET_FRAMES = 6; +const SURNAMES = [ + "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", + "Hayes", "Irwin", "Jordan", "Keller", "Lewis", "Morris", "Nelson", + "Owens", "Parker", "Quinn", "Reed", "Sullivan", "Turner", "Underwood", + "Vaughn", "Walker", "Xavier", "Young", "Zimmerman", +] as const; +const GIVEN_NAMES = [ + "Avery", "Chloe", "Elliot", "Harper", "Jamie", "Morgan", "Riley", "Taylor", +] as const; + +function contact(index: number) { + const surname = SURNAMES[Math.floor(index * SURNAMES.length / CONTACT_COUNT)]; + const given = GIVEN_NAMES[(index * 5 + surname.length) % GIVEN_NAMES.length]; + const line = String((index * 17 + 31) % 100).padStart(2, "0"); + return { + given, + surname, + ordinal: String(index + 1).padStart(5, "0"), + phone: `(415) 555-01${line}`, + email: `${given}@${surname}.com`.toLowerCase(), + }; +} + +function NavigationBar(props: { title: string; back?: boolean }) { + return ( + + + {props.title} + + + + MENU: Back + + + + + ); +} + +function ContactRow(props: { index: Accessor }) { + const item = createMemo(() => contact(props.index())); + return ( + + {item().given} + + {item().surname} + + + {item().ordinal} + + + ); +} + +function ContactSeparator() { + return ( + + + + ); +} + +function RecycledContactList(props: { + scroller: Scroller; + selectionY: Accessor; + afterStep: () => void; +}) { + const [firstIndex, setFirstIndex] = createSignal(0); + + onFrame(() => { + props.scroller.step(); + const first = Math.max( + 0, + Math.min( + CONTACT_COUNT - CONTACT_WINDOW_ROWS, + Math.floor(props.scroller.offset() / CONTACT_ROW_HEIGHT) - 1, + ), + ); + setFirstIndex(first); + props.afterStep(); + }); + + return ( + + + {() => } + + + + + {(slot) => firstIndex() + slot} />} + + + + ); +} + +export default function ContactsPage() { + const [destinationIndex, setDestinationIndex] = createSignal(0); + const [selectionY, setSelectionY] = createSignal(0); + const [detailIndex, setDetailIndex] = createSignal(0); + const [detailOpen, setDetailOpen] = createSignal(false); + let listPanel: NodeMirror | undefined; + let detailPanel: NodeMirror | undefined; + let wheelDirection = 0; + let wheelBurst = 0; + let wheelTargetIndex = 0; + let wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; + const listScroller = createScroller({ + max: () => CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT, + extent: () => CONTACT_LIST_HEIGHT, + }); + const detail = createMemo(() => contact(detailIndex())); + + const resetWheelAcceleration = () => { + wheelDirection = 0; + wheelBurst = 0; + wheelTargetIndex = destinationIndex(); + wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; + }; + + const moveSelection = (delta: number) => { + const nextTarget = Math.max( + 0, + Math.min(CONTACT_COUNT - 1, wheelTargetIndex + delta), + ); + if (nextTarget === wheelTargetIndex) return; + wheelTargetIndex = nextTarget; + const nextSelected = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + setDestinationIndex(nextSelected); + setSelectionY(contactSelectionY(nextSelected, listScroller.offset())); + const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + const target = contactScrollTarget( + wheelTargetIndex, + listScroller.intent(), + maxOffset, + ); + if (target !== null) { + listScroller.springTo(target, { + overshootPx: CONTACT_SPRING_OVERSHOOT, + stiffness: CONTACT_SPRING_STIFFNESS, + damping: CONTACT_SPRING_DAMPING, + }); + } + }; + + const updateVisualSelection = () => { + const nextSelected = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + setDestinationIndex(nextSelected); + setSelectionY(contactSelectionY( + nextSelected, + listScroller.offset(), + )); + }; + + const settleReleasedSelection = () => { + const selectedIndex = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + wheelTargetIndex = selectedIndex; + setDestinationIndex(selectedIndex); + const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + const target = contactScrollTarget( + selectedIndex, + listScroller.offset(), + maxOffset, + ); + // Freeze the accumulated wheel velocity first. A fresh zero-velocity + // spring may then pull the selected row from the half-row clip limit to + // the +3/-3 resting anchor; no pre-release momentum survives. + listScroller.stop(); + if (target !== null) { + listScroller.springTo(target, { + stiffness: CONTACT_SPRING_STIFFNESS, + damping: CONTACT_SPRING_DAMPING, + }); + } + }; + + const acceleratedWheelDelta = (direction: -1 | 1) => { + if (wheelDirection !== direction || wheelIdleFrames >= WHEEL_ACCEL_RESET_FRAMES) { + wheelDirection = direction; + wheelBurst = 0; + wheelTargetIndex = destinationIndex(); + } else { + wheelBurst += 1; + } + wheelIdleFrames = 0; + return direction * wheelMultiplier(wheelBurst); + }; + + onFrame((buttons) => { + if (detailOpen()) return; + if ((buttons & BTN.UP) !== 0) { + moveSelection(acceleratedWheelDelta(-1)); + } else if ((buttons & BTN.DOWN) !== 0) { + moveSelection(acceleratedWheelDelta(1)); + } else { + wheelIdleFrames = Math.min(WHEEL_ACCEL_RESET_FRAMES, wheelIdleFrames + 1); + if (wheelDirection !== 0 && wheelIdleFrames === 1) { + settleReleasedSelection(); + } + if (wheelDirection !== 0 && wheelIdleFrames === WHEEL_ACCEL_RESET_FRAMES) { + resetWheelAcceleration(); + } + } + }); + + onButtonPress(BTN.CIRCLE, () => { + if (detailOpen()) return; + resetWheelAcceleration(); + setDetailIndex(destinationIndex()); + setDetailOpen(true); + if (listPanel) animate(listPanel, "translateX", -64, { dur: 110, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 0, { dur: 110, easing: "out" }); + }, { latched: true }); + onButtonPress(BTN.TRIANGLE, () => { + if (!detailOpen()) return; + resetWheelAcceleration(); + setDetailOpen(false); + if (listPanel) animate(listPanel, "translateX", 0, { dur: 110, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 320, { dur: 110, easing: "out" }); + }, { latched: true }); + + return ( + + (listPanel = node)} + class="absolute left-0 top-0 w-[320] h-[240] bg-white overflow-hidden" + > + + + + + + + (detailPanel = node)} + class="absolute left-0 top-0 w-[320] h-[240] bg-[#c5ccd3] overflow-hidden" + style={{ translateX: 320 }} + > + + + {detail().given} {detail().surname} + Contact {detail().ordinal} of 10,000 + + + + + mobile + {detail().phone} + + + + email + {detail().email} + + + + + + + + ); +} diff --git a/hosts/rockbox/demo/input-test-page.tsx b/hosts/rockbox/demo/input-test-page.tsx new file mode 100644 index 000000000..b4a994a4b --- /dev/null +++ b/hosts/rockbox/demo/input-test-page.tsx @@ -0,0 +1,87 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { onFrame } from "@pocketjs/framework/lifecycle"; + +const INPUTS = [ + [BTN.TRIANGLE, "MENU"], + [BTN.LEFT, "LEFT"], + [BTN.CIRCLE, "SELECT"], + [BTN.RIGHT, "RIGHT"], + [BTN.START, "PLAY"], + [BTN.UP, "WHEEL -"], + [BTN.DOWN, "WHEEL +"], +] as const; + +function InputPill(props: { + label: string; + mask: number; + active: (mask: number) => boolean; +}) { + return ( + + + {props.label} + + + ); +} + +export default function InputTestPage() { + const [held, setHeld] = createSignal(0); + const [flash, setFlash] = createSignal(0); + const [lastInput, setLastInput] = createSignal("NONE"); + const [eventCount, setEventCount] = createSignal(0); + let previous = 0; + let flashFrames = 0; + + onFrame((buttons) => { + const pressed = buttons & ~previous; + previous = buttons; + setHeld(buttons); + if (pressed !== 0) { + const names = INPUTS + .filter(([mask]) => (pressed & mask) !== 0) + .map(([, label]) => label); + setLastInput(names.join(" + ")); + setEventCount((value) => value + names.length); + setFlash(pressed); + flashFrames = 10; + } else if (flashFrames > 0) { + flashFrames -= 1; + if (flashFrames === 0) setFlash(0); + } + }); + + const active = (mask: number) => ((held() | flash()) & mask) !== 0; + + return ( + + Hardware Input Test + Green means held or recently pulsed + + + + {INPUTS.map(([mask, label]) => ( + + ))} + + + + + + LAST EDGE + {lastInput()} + + + EVENTS + {eventCount()} + + + + ); +} diff --git a/hosts/rockbox/demo/main.tsx b/hosts/rockbox/demo/main.tsx new file mode 100644 index 000000000..c486a58fd --- /dev/null +++ b/hosts/rockbox/demo/main.tsx @@ -0,0 +1,40 @@ +import { Show, createSignal } from "solid-js"; +import { mount } from "@pocketjs/framework/solid"; +import { View } from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import ContactsPage from "./contacts-page.tsx"; +import InputTestPage from "./input-test-page.tsx"; +import StandardPage from "./standard-page.tsx"; + +const PAGE_COUNT = 3; + +function RockboxDemo() { + const [page, setPage] = createSignal(0); + + onButtonPress(BTN.LEFT | BTN.RIGHT, (pressed, buttons) => { + if ((buttons & BTN.CIRCLE) === 0) return; + if ((pressed & BTN.LEFT) !== 0) { + setPage((value) => (value + PAGE_COUNT - 1) % PAGE_COUNT); + } else if ((pressed & BTN.RIGHT) !== 0) { + setPage((value) => (value + 1) % PAGE_COUNT); + } + }); + + return ( + + + + + + + + + + + + + ); +} + +mount(() => ); diff --git a/hosts/rockbox/demo/standard-page.tsx b/hosts/rockbox/demo/standard-page.tsx new file mode 100644 index 000000000..37d18dd11 --- /dev/null +++ b/hosts/rockbox/demo/standard-page.tsx @@ -0,0 +1,93 @@ +import { Show, createSignal, onMount } from "solid-js"; +import { animate } from "@pocketjs/framework/animation"; +import { TICKS_PER_SECOND } from "@pocketjs/framework/clock"; +import { + Image, + Text, + View, + type NodeMirror, +} from "@pocketjs/framework/components"; +import { createSpriteAnimation } from "@pocketjs/framework/lifecycle"; +import { frameworkName } from "@pocketjs/framework/solid"; + +const SPINNER_FRAMES = [ + "spinner-00.svg", + "spinner-01.svg", + "spinner-02.svg", + "spinner-03.svg", + "spinner-04.svg", + "spinner-05.svg", + "spinner-06.svg", + "spinner-07.svg", +] as const; + +function Stat(props: { label: string; value: string; cls: string }) { + return ( + + {props.value} + {props.label} + + ); +} + +/** The official PocketJS Hero demo, fixed to the iPod's 320x240 viewport. */ +export default function StandardPage() { + const [count, setCount] = createSignal(0); + const spinner = createSpriteAnimation(SPINNER_FRAMES, { frameStep: 3 }); + let underline: NodeMirror | undefined; + + onMount(() => { + if (underline) animate(underline, "width", 196, { + dur: 700, + easing: "out", + delay: 150, + }); + }); + + return ( + + + + + + PocketJS + {frameworkName()} + QUICKJS + + + + + + + + + + + ONE RUST CORE · ONE JSX APP + + JSX at 60 FPS. + + + + Flexbox, springs and baked type on iPod classic. + + + + setCount((value) => value + 1)} + > + Press Select + + Count: {count()} + 3}> + Reactive. + + + + ); +} diff --git a/hosts/rockbox/framebuffer.c b/hosts/rockbox/framebuffer.c new file mode 100644 index 000000000..211afedb0 --- /dev/null +++ b/hosts/rockbox/framebuffer.c @@ -0,0 +1,14 @@ +#include "framebuffer.h" + +void rockbox_bgra_to_rgb565(uint16_t *out, const uint8_t *bgra, size_t pixels) { + size_t index; + if (out == 0 || bgra == 0) return; + for (index = 0; index < pixels; ++index) { + const uint8_t blue = bgra[index * 4u]; + const uint8_t green = bgra[index * 4u + 1u]; + const uint8_t red = bgra[index * 4u + 2u]; + out[index] = (uint16_t)(((uint16_t)(red & 0xf8u) << 8u) | + ((uint16_t)(green & 0xfcu) << 3u) | + ((uint16_t)blue >> 3u)); + } +} diff --git a/hosts/rockbox/framebuffer.h b/hosts/rockbox/framebuffer.h new file mode 100644 index 000000000..d34351e57 --- /dev/null +++ b/hosts/rockbox/framebuffer.h @@ -0,0 +1,9 @@ +#ifndef POCKETJS_ROCKBOX_FRAMEBUFFER_H +#define POCKETJS_ROCKBOX_FRAMEBUFFER_H + +#include +#include + +void rockbox_bgra_to_rgb565(uint16_t *out, const uint8_t *bgra, size_t pixels); + +#endif diff --git a/hosts/rockbox/input.c b/hosts/rockbox/input.c new file mode 100644 index 000000000..4a790de43 --- /dev/null +++ b/hosts/rockbox/input.c @@ -0,0 +1,30 @@ +#include "input.h" + +#include "pocket_spec.h" + +uint32_t rockbox_input_buttons( + int held, + int event, + const RockboxInputCodes *codes +) { + uint32_t buttons = 0; + int physical; + if (codes == 0) return 0; + + physical = held | event; + if ((physical & codes->select) != 0) buttons |= POCKET_BTN_CIRCLE; + if ((physical & codes->menu) != 0) buttons |= POCKET_BTN_TRIANGLE; + if ((physical & codes->left) != 0) buttons |= POCKET_BTN_LEFT; + if ((physical & codes->right) != 0) buttons |= POCKET_BTN_RIGHT; + if ((physical & codes->play) != 0) buttons |= POCKET_BTN_START; + + /* Wheel motion is queued as an event, not reported by button_status(). */ + if ((event & codes->scroll_forward) != 0) buttons |= POCKET_BTN_DOWN; + if ((event & codes->scroll_back) != 0) buttons |= POCKET_BTN_UP; + return buttons; +} + +bool rockbox_input_exit_requested(int event, const RockboxInputCodes *codes) { + if (codes == 0) return false; + return (event & codes->menu) != 0 && (event & codes->repeat) != 0; +} diff --git a/hosts/rockbox/input.h b/hosts/rockbox/input.h new file mode 100644 index 000000000..65c38f371 --- /dev/null +++ b/hosts/rockbox/input.h @@ -0,0 +1,25 @@ +#ifndef POCKETJS_ROCKBOX_INPUT_H +#define POCKETJS_ROCKBOX_INPUT_H + +#include +#include + +typedef struct { + int select; + int menu; + int left; + int right; + int play; + int scroll_forward; + int scroll_back; + int repeat; +} RockboxInputCodes; + +uint32_t rockbox_input_buttons( + int held, + int event, + const RockboxInputCodes *codes +); +bool rockbox_input_exit_requested(int event, const RockboxInputCodes *codes); + +#endif diff --git a/hosts/rockbox/main.c b/hosts/rockbox/main.c new file mode 100644 index 000000000..e253d1ed2 --- /dev/null +++ b/hosts/rockbox/main.c @@ -0,0 +1,202 @@ +#include "plugin.h" +#include + +#include "input.h" +#include "pocket_runtime.h" +#include "pocket_spec.h" + +#if CONFIG_KEYPAD != IPOD_4G_PAD +#error "PocketJS Rockbox host currently supports iPod classic click-wheel targets only" +#endif +#if LCD_WIDTH != 320 || LCD_HEIGHT != 240 || LCD_DEPTH != 16 +#error "PocketJS Rockbox host requires the iPod classic 320x240 RGB565 display" +#endif + +extern const unsigned char pocket_app_js[]; +extern const unsigned int pocket_app_js_len; +extern const unsigned char pocket_app_pak[]; +extern const unsigned int pocket_app_pak_len; + +#define POCKETJS_RUNTIME_STACK_SIZE (16u * 1024u * 1024u) + +static fb_data display[LCD_WIDTH * LCD_HEIGHT] MEM_ALIGN_ATTR; +static int boot_stage; +static size_t runtime_heap_size; +static enum plugin_status runtime_status; + +static const RockboxInputCodes input_codes = { + .select = BUTTON_SELECT, + .menu = BUTTON_MENU, + .left = BUTTON_LEFT, + .right = BUTTON_RIGHT, + .play = BUTTON_PLAY, + .scroll_forward = BUTTON_SCROLL_FWD, + .scroll_back = BUTTON_SCROLL_BACK, + .repeat = BUTTON_REPEAT, +}; + +void *pocket_host_alloc(size_t size) { return tlsf_malloc(size); } +void *pocket_host_realloc(void *pointer, size_t size) { + return tlsf_realloc(pointer, size); +} +void pocket_host_free(void *pointer) { tlsf_free(pointer); } +void pocket_host_boot_stage(int stage) { boot_stage = stage; } + +static enum plugin_status show_runtime_error(void) { + const char *message = pocket_runtime_error(); + rb->splashf( + HZ * 8, + "PJS S%d H%luK: %s", + boot_stage, + (unsigned long)(runtime_heap_size / 1024u), + message && *message ? message : "runtime error" + ); + return PLUGIN_ERROR; +} + +static void pocketjs_runtime_thread(void) { + enum plugin_status status = PLUGIN_OK; + int pending_event = BUTTON_NONE; + int cadence = 0; + bool runtime_ready = false; + + if (!pocket_runtime_boot( + pocket_app_js, + pocket_app_js_len, + pocket_app_pak, + pocket_app_pak_len, + LCD_WIDTH, + LCD_HEIGHT + )) { + status = show_runtime_error(); + goto cleanup; + } + runtime_ready = true; + + while (true) { + const long event = rb->button_get_w_tmo(1); + uint32_t buttons; + int damage[4]; + int damage_width; + int damage_height; + + if (event != BUTTON_NONE) { + if (rockbox_input_exit_requested((int)event, &input_codes)) break; + if (rb->default_event_handler(event) == SYS_USB_CONNECTED) { + status = PLUGIN_USB_CONNECTED; + break; + } + pending_event |= (int)event; + } + + /* Rockbox's native tick is normally 100 Hz; retain exactly 60 guest + turns per second without relying on fractional sleep durations. */ + cadence += 60; + if (cadence < HZ) continue; + cadence -= HZ; + + buttons = rockbox_input_buttons(rb->button_status(), pending_event, &input_codes); + pending_event = BUTTON_NONE; + if (!pocket_runtime_tick_analog(buttons, POCKET_ANALOG_CENTER)) { + status = show_runtime_error(); + break; + } + + if (!pocket_runtime_render_rgb565( + (uint16_t *)display, + LCD_WIDTH * LCD_HEIGHT + ) || pocket_runtime_width() != LCD_WIDTH || + pocket_runtime_height() != LCD_HEIGHT) { + rb->splash(HZ * 3, "PocketJS: invalid framebuffer"); + status = PLUGIN_ERROR; + break; + } + if (!pocket_runtime_damage_bounds(damage)) continue; + + if (damage[0] < 0) damage[0] = 0; + if (damage[1] < 0) damage[1] = 0; + if (damage[2] > LCD_WIDTH) damage[2] = LCD_WIDTH; + if (damage[3] > LCD_HEIGHT) damage[3] = LCD_HEIGHT; + damage_width = damage[2] - damage[0]; + damage_height = damage[3] - damage[1]; + if (damage_width <= 0 || damage_height <= 0) continue; + + rb->lcd_bitmap_part( + display, + damage[0], + damage[1], + LCD_WIDTH, + damage[0], + damage[1], + damage_width, + damage_height + ); + rb->lcd_update_rect( + damage[0], + damage[1], + damage_width, + damage_height + ); + } + +cleanup: + if (runtime_ready) pocket_runtime_shutdown(); + runtime_status = status; +} + +enum plugin_status plugin_start(const void *parameter) { + size_t audio_size = 0; + size_t heap_size; + unsigned int thread_id; + unsigned char *audio_buffer; + unsigned char *heap; + + (void)parameter; + /* QuickJS source evaluation needs substantially more native stack than the + 8 KiB Rockbox main thread provides. Reserve a stable 16 MiB execution + stack from the audio buffer; the rest is the PocketJS allocation heap. */ + rb->audio_stop(); + audio_buffer = rb->plugin_get_audio_buffer(&audio_size); + if (audio_buffer == 0 || + audio_size < POCKETJS_RUNTIME_STACK_SIZE + 2u * 1024u * 1024u) { + rb->splash(HZ * 3, "PocketJS: not enough audio memory"); + return PLUGIN_ERROR; + } + + heap = audio_buffer + POCKETJS_RUNTIME_STACK_SIZE; + heap_size = audio_size - POCKETJS_RUNTIME_STACK_SIZE; + runtime_heap_size = heap_size; + if (init_memory_pool(heap_size, heap) == (size_t)-1) { + rb->splash(HZ * 3, "PocketJS: heap init failed"); + return PLUGIN_ERROR; + } + +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(true); +#endif + rb->backlight_on(); + + runtime_status = PLUGIN_ERROR; + thread_id = rb->create_thread( + pocketjs_runtime_thread, + audio_buffer, + POCKETJS_RUNTIME_STACK_SIZE, + 0, + "pocketjs" + IF_PRIO(, PRIORITY_USER_INTERFACE) + IF_COP(, CPU) + ); + if (thread_id == 0) { + rb->splash(HZ * 3, "PocketJS: thread creation failed"); +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(false); +#endif + return PLUGIN_ERROR; + } + + rb->thread_wait(thread_id); +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(false); +#endif + return runtime_status; +} diff --git a/hosts/rockbox/pocketjs.make b/hosts/rockbox/pocketjs.make new file mode 100644 index 000000000..bd92b0e38 --- /dev/null +++ b/hosts/rockbox/pocketjs.make @@ -0,0 +1,26 @@ +POCKETJS_SRCDIR := $(APPSDIR)/plugins/pocketjs +POCKETJS_BUILDDIR := $(BUILDDIR)/apps/plugins/pocketjs + +POCKETJS_SRC := \ + $(POCKETJS_SRCDIR)/main.c \ + $(POCKETJS_SRCDIR)/input.c \ + $(POCKETJS_SRCDIR)/compat.c \ + $(POCKETJS_SRCDIR)/runtime_port.c \ + $(POCKETJS_SRCDIR)/app_data.c \ + $(POCKETJS_SRCDIR)/qjs_quickjs.c \ + $(POCKETJS_SRCDIR)/qjs_cutils.c \ + $(POCKETJS_SRCDIR)/qjs_libregexp.c \ + $(POCKETJS_SRCDIR)/qjs_libunicode.c \ + $(POCKETJS_SRCDIR)/qjs_dtoa.c +POCKETJS_OBJ := $(call c2obj,$(POCKETJS_SRC)) +POCKETJS_CORE := $(POCKETJS_SRCDIR)/libpocketjs_rockbox_core.a + +OTHER_SRC += $(POCKETJS_SRC) +ROCKS += $(POCKETJS_BUILDDIR)/pocketjs.rock + +$(POCKETJS_OBJ): $(BUILDDIR)/sysfont.h +$(POCKETJS_BUILDDIR)/pocketjs.rock: $(POCKETJS_OBJ) $(POCKETJS_CORE) $(TLSFLIB) +$(POCKETJS_BUILDDIR)/pocketjs.rock: PLUGINFLAGS += \ + -I$(POCKETJS_SRCDIR) \ + -Wno-sign-compare -Wno-unused-parameter +$(POCKETJS_BUILDDIR)/pocketjs.rock: PLUGINLDFLAGS += -lm diff --git a/hosts/rockbox/qjs_config.h b/hosts/rockbox/qjs_config.h new file mode 100644 index 000000000..58fed2a6a --- /dev/null +++ b/hosts/rockbox/qjs_config.h @@ -0,0 +1,10 @@ +#ifndef POCKETJS_ROCKBOX_QJS_CONFIG_H +#define POCKETJS_ROCKBOX_QJS_CONFIG_H + +#define POCKETJS_NO_MALLOC_USABLE_SIZE 1 +#define POCKETJS_NO_ATOMICS 1 +#define POCKETJS_FIXED_TIMEZONE 1 +#define CONFIG_VERSION "pocket-rockbox-ipod-classic" +#include "compat.h" + +#endif diff --git a/hosts/rockbox/qjs_cutils.c b/hosts/rockbox/qjs_cutils.c new file mode 100644 index 000000000..73ccf6e54 --- /dev/null +++ b/hosts/rockbox/qjs_cutils.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "cutils.c" diff --git a/hosts/rockbox/qjs_dtoa.c b/hosts/rockbox/qjs_dtoa.c new file mode 100644 index 000000000..b245a447d --- /dev/null +++ b/hosts/rockbox/qjs_dtoa.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "dtoa.c" diff --git a/hosts/rockbox/qjs_libregexp.c b/hosts/rockbox/qjs_libregexp.c new file mode 100644 index 000000000..30dd18bc0 --- /dev/null +++ b/hosts/rockbox/qjs_libregexp.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "libregexp.c" diff --git a/hosts/rockbox/qjs_libunicode.c b/hosts/rockbox/qjs_libunicode.c new file mode 100644 index 000000000..a20bb292c --- /dev/null +++ b/hosts/rockbox/qjs_libunicode.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "libunicode.c" diff --git a/hosts/rockbox/qjs_quickjs.c b/hosts/rockbox/qjs_quickjs.c new file mode 100644 index 000000000..c3565ee45 --- /dev/null +++ b/hosts/rockbox/qjs_quickjs.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "quickjs.c" diff --git a/hosts/rockbox/runtime_port.c b/hosts/rockbox/runtime_port.c new file mode 100644 index 000000000..fca0e3284 --- /dev/null +++ b/hosts/rockbox/runtime_port.c @@ -0,0 +1,7 @@ +#define POCKETJS_TARGET_ID "rockbox-ipod-classic-dev" +#define POCKETJS_HOST_ABI 9 +#define POCKET_RASTER_DENSITY 1 +#define POCKET_RUNTIME_JS_STACK_SIZE (8 * 1024 * 1024) +#define POCKET_RUNTIME_REPORT_BOOT_STAGE 1 +#include "compat.h" +#include "pocket_runtime.c" diff --git a/hosts/rockbox/sys/time.h b/hosts/rockbox/sys/time.h new file mode 100644 index 000000000..e06399729 --- /dev/null +++ b/hosts/rockbox/sys/time.h @@ -0,0 +1,13 @@ +#ifndef POCKETJS_ROCKBOX_SYS_TIME_H +#define POCKETJS_ROCKBOX_SYS_TIME_H + +#include + +struct timeval { + time_t tv_sec; + long tv_usec; +}; + +int gettimeofday(struct timeval *tv, void *timezone); + +#endif diff --git a/hosts/rockbox/targets/armv5te-rockbox-eabi.json b/hosts/rockbox/targets/armv5te-rockbox-eabi.json new file mode 100644 index 000000000..b328faabe --- /dev/null +++ b/hosts/rockbox/targets/armv5te-rockbox-eabi.json @@ -0,0 +1,23 @@ +{ + "abi": "eabi", + "arch": "arm", + "c-enum-min-bits": 8, + "crt-objects-fallback": "false", + "cpu": "arm926ej-s", + "data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64", + "emit-debug-gdb-scripts": false, + "features": "+v5te,+soft-float,+strict-align", + "frame-pointer": "always", + "has-thumb-interworking": true, + "linker": "rust-lld", + "linker-flavor": "gnu-lld", + "llvm-floatabi": "soft", + "llvm-target": "armv5te-none-eabi", + "max-atomic-width": 32, + "os": "none", + "panic-strategy": "abort", + "relocation-model": "static", + "target-endian": "little", + "target-pointer-width": 32, + "vendor": "unknown" +} diff --git a/hosts/rockbox/tests/platform_test.c b/hosts/rockbox/tests/platform_test.c new file mode 100644 index 000000000..0e60b67d0 --- /dev/null +++ b/hosts/rockbox/tests/platform_test.c @@ -0,0 +1,47 @@ +#include +#include + +#include "../framebuffer.h" +#include "../input.h" +#include "../../iphone2g/pocket_spec.h" + +static void framebuffer_test(void) { + const uint8_t bgra[] = { + 0x00, 0x00, 0xff, 0xff, + 0x00, 0xff, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, + }; + uint16_t rgb565[4] = {0}; + rockbox_bgra_to_rgb565(rgb565, bgra, 4); + assert(rgb565[0] == 0xf800u); + assert(rgb565[1] == 0x07e0u); + assert(rgb565[2] == 0x001fu); + assert(rgb565[3] == 0xffffu); +} + +static void input_test(void) { + const RockboxInputCodes codes = { + .select = 1 << 0, + .menu = 1 << 1, + .left = 1 << 2, + .right = 1 << 3, + .scroll_forward = 1 << 4, + .scroll_back = 1 << 5, + .play = 1 << 6, + .repeat = 1 << 7, + }; + assert(rockbox_input_buttons(codes.select, 0, &codes) == POCKET_BTN_CIRCLE); + assert(rockbox_input_buttons(0, codes.scroll_forward, &codes) == POCKET_BTN_DOWN); + assert(rockbox_input_buttons(0, codes.scroll_back, &codes) == POCKET_BTN_UP); + assert(rockbox_input_buttons(codes.left | codes.play, 0, &codes) == + (POCKET_BTN_LEFT | POCKET_BTN_START)); + assert(rockbox_input_exit_requested(codes.menu | codes.repeat, &codes)); + assert(!rockbox_input_exit_requested(codes.menu, &codes)); +} + +int main(void) { + framebuffer_test(); + input_test(); + return 0; +} diff --git a/hosts/symbian/runtime/pocketjs_symbian_core.h b/hosts/symbian/runtime/pocketjs_symbian_core.h index a59a1216b..fd6dba132 100644 --- a/hosts/symbian/runtime/pocketjs_symbian_core.h +++ b/hosts/symbian/runtime/pocketjs_symbian_core.h @@ -99,6 +99,7 @@ int32_t ui_gl_render_over( int32_t window_height ); const uint8_t *ui_render_incremental(void); +int32_t ui_render_rgb565_incremental(uint16_t *framebuffer, size_t pixel_count); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); uint32_t ui_framebuffer_stride(void); diff --git a/package.json b/package.json index b95cef37e..0fbad207c 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ }, "description": "A portable application runtime that turns modern component code into native pixels across radically different hardware: Solid, Vue Vapor and Octane over a native Rust core, with build-time Tailwind styling and 60 FPS animation under an 8 MB memory budget.", "scripts": { + "rockbox": "bun tools/rockbox.ts", "bootstrap": "bun tools/bootstrap.ts", "pocket": "bun tools/pocket.ts", "build": "bun tools/build.ts", diff --git a/tests/kinetics.test.ts b/tests/kinetics.test.ts index 3445613b5..c8ea8d310 100644 --- a/tests/kinetics.test.ts +++ b/tests/kinetics.test.ts @@ -190,6 +190,90 @@ describe("chase (im parity)", () => { }); }); +describe("retargetable spring", () => { + test("approaches through a bounded overshoot and returns to the exact final target", () => { + const s = createScroller({ max: () => 1000 }); + s.springTo(400, { overshootPx: 12 }); + expect(s.intent()).toBe(400); + const t = trace(s); + expect(Math.max(...t)).toBeGreaterThan(400); + expect(Math.max(...t)).toBeLessThanOrEqual(412); + expect(t[t.length - 1]).toBe(400); + }); + + test("retargeting preserves velocity and never jumps the current offset", () => { + const s = createScroller({ max: () => 2000 }); + s.springTo(300, { overshootPx: 12 }); + s.step(); + s.step(); + const beforeOffset = s.offset(); + const beforeVelocity = s.velocity(); + s.springTo(900, { overshootPx: 12 }); + expect(s.offset()).toBe(beforeOffset); + expect(s.velocity()).toBe(beforeVelocity); + expect(s.intent()).toBe(900); + expect(trace(s).at(-1)).toBe(900); + }); + + test("accepts stronger per-follow spring constants without changing defaults", () => { + const normal = createScroller({ max: () => 2000 }); + const strong = createScroller({ max: () => 2000 }); + normal.springTo(1000); + strong.springTo(1000, { stiffness: 480, damping: 44 }); + normal.step(); + strong.step(); + expect(strong.offset()).toBeGreaterThan(normal.offset()); + expect(trace(normal).at(-1)).toBe(1000); + expect(trace(strong).at(-1)).toBe(1000); + }); + + test("a reverse target brakes the preserved velocity before moving back", () => { + const s = createScroller({ max: () => 2000 }); + s.springTo(1200, { overshootPx: 12 }); + for (let i = 0; i < 5; i++) s.step(); + const atReverse = s.offset(); + expect(s.velocity()).toBeGreaterThan(0); + s.springTo(0, { overshootPx: 12 }); + const t = trace(s); + expect(Math.max(...t)).toBeGreaterThan(atReverse); + expect(Math.min(...t)).toBeGreaterThanOrEqual(-12); + expect(t.at(-1)).toBe(0); + }); + + test("clamps final intent while permitting only the requested edge overshoot", () => { + const s = createScroller({ max: () => 100, initial: 50 }); + s.springTo(999, { overshootPx: 12 }); + expect(s.intent()).toBe(100); + const down = trace(s); + expect(Math.max(...down)).toBeLessThanOrEqual(112); + expect(down.at(-1)).toBe(100); + + s.springTo(-999, { overshootPx: 12 }); + expect(s.intent()).toBe(0); + const up = trace(s); + expect(Math.min(...up)).toBeGreaterThanOrEqual(-12); + expect(up.at(-1)).toBe(0); + }); + + test("30 Hz spring trajectory is the 60 Hz trajectory subsampled", () => { + withHz(60); + const s60 = createScroller({ max: () => 2000 }); + s60.springTo(1000, { overshootPx: 12 }); + const t60 = trace(s60); + + withHz(30); + const s30 = createScroller({ max: () => 2000 }); + s30.springTo(1000, { overshootPx: 12 }); + const t30 = trace(s30); + + for (let i = 0; i < t30.length; i++) { + const at60 = 2 * i + 1; + expect(t30[i]).toBe(t60[Math.min(at60, t60.length - 1)]); + } + expect(t30.at(-1)).toBe(t60.at(-1)); + }); +}); + describe("tween + snap", () => { test("scrollTo lands exactly at the target after round(durMs·hz/1000) frames", () => { const s = createScroller({ max: () => 1000 }); diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts new file mode 100644 index 000000000..7b585db2f --- /dev/null +++ b/tests/rockbox-contact-motion.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { createScroller } from "../framework/src/kinetics.ts"; +import { + CONTACT_CENTER_ANCHOR_Y, + CONTACT_DOWN_ANCHOR_Y, + CONTACT_LIST_HEIGHT, + CONTACT_MAX_OFFSCREEN_PX, + CONTACT_MAX_OFFSCREEN_ROWS, + CONTACT_ROW_HEIGHT, + CONTACT_UP_ANCHOR_Y, + contactSelectionY, + contactScrollTarget, + contactVisibleIndex, + wheelMultiplier, +} from "../hosts/rockbox/demo/contact-motion.ts"; + +const COUNT = 10_000; +const MAX_OFFSET = COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + +describe("Rockbox contact wheel motion", () => { + test("keeps slow downward selection inside the viewport before scrolling", () => { + for (let index = 1; index <= 3; index++) { + expect(contactScrollTarget(index, 0, MAX_OFFSET)).toBeNull(); + } + const target = contactScrollTarget(4, 0, MAX_OFFSET); + expect(target).toBe(6); + expect(4 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); + }); + + test("uses +3/-3 rows from the viewport edges as resting anchors", () => { + expect(CONTACT_CENTER_ANCHOR_Y).toBe(87); + expect(CONTACT_UP_ANCHOR_Y).toBe(3 * CONTACT_ROW_HEIGHT); + expect(CONTACT_DOWN_ANCHOR_Y).toBe( + CONTACT_LIST_HEIGHT - 3 * CONTACT_ROW_HEIGHT, + ); + const down = contactScrollTarget(20, 0, MAX_OFFSET)!; + expect(20 * CONTACT_ROW_HEIGHT - down).toBe(CONTACT_DOWN_ANCHOR_Y); + + const up = contactScrollTarget(10, 600, MAX_OFFSET)!; + expect(10 * CONTACT_ROW_HEIGHT - up).toBe(CONTACT_UP_ANCHOR_Y); + }); + + test("lets a 1024-row destination leave the screen before the list follows", () => { + expect(wheelMultiplier(30)).toBe(1024); + expect(wheelMultiplier(300)).toBe(1024); + const index = 1024; + expect(index * CONTACT_ROW_HEIGHT).toBeGreaterThan(CONTACT_LIST_HEIGHT); + const target = contactScrollTarget(index, 0, MAX_OFFSET)!; + expect(index * CONTACT_ROW_HEIGHT - target).toBe(CONTACT_DOWN_ANCHOR_Y); + }); + + test("keeps the independent selection bar within 0.5 rows of either edge", () => { + expect(CONTACT_MAX_OFFSCREEN_ROWS).toBe(0.5); + expect(CONTACT_MAX_OFFSCREEN_PX).toBe(CONTACT_ROW_HEIGHT / 2); + const down = contactSelectionY(1024, 0); + expect(down + CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT) + .toBe(CONTACT_MAX_OFFSCREEN_PX); + + const up = contactSelectionY(0, 3000); + expect(-up).toBe(CONTACT_MAX_OFFSCREEN_PX); + + expect(contactSelectionY(12, 300)).toBe(60); + }); + + test("keeps the real selected contact within 0.5 rows while a remote target sprints", () => { + const down = contactVisibleIndex(1024, 0, COUNT); + expect(down).toBe(6); + expect(down * CONTACT_ROW_HEIGHT + CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT) + .toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + + const offset = 30_000; + const up = contactVisibleIndex(0, offset, COUNT); + expect(offset - up * CONTACT_ROW_HEIGHT) + .toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + }); + + test("clamps at real data edges instead of creating blank contacts", () => { + expect(contactScrollTarget(0, 500, MAX_OFFSET)).toBe(0); + expect(contactScrollTarget(COUNT - 1, 0, MAX_OFFSET)).toBe(MAX_OFFSET); + }); + + test("release discards wheel velocity and leaves only the anchor spring", () => { + const scroller = createScroller({ max: () => 2000 }); + scroller.springTo(900, { + overshootPx: 12, + stiffness: 480, + damping: 44, + }); + for (let frame = 0; frame < 4; frame++) scroller.step(); + const releasedAt = scroller.offset(); + + scroller.stop(); + expect(scroller.velocity()).toBe(0); + scroller.springTo(900, { stiffness: 480, damping: 44 }); + expect(scroller.velocity()).toBe(0); + const trace = [releasedAt]; + for (let frame = 0; frame < 240 && scroller.state() !== "idle"; frame++) { + scroller.step(); + trace.push(scroller.offset()); + } + + expect(trace[1]).toBeGreaterThan(releasedAt); + expect(Math.max(...trace)).toBeLessThanOrEqual(900); + expect(trace.at(-1)).toBe(900); + }); +}); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts new file mode 100644 index 000000000..b15f47186 --- /dev/null +++ b/tests/rockbox-profile.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + ROCKBOX_IPOD_CLASSIC_HOST_ABI, + ROCKBOX_IPOD_CLASSIC_TARGET_ID, + resolveRockboxBuildPlan, +} from "../tools/rockbox-profile.ts"; +import { parseClassLiteral } from "../framework/compiler/tailwind.ts"; + +const root = join(import.meta.dir, ".."); +const manifest = JSON.parse( + readFileSync(join(root, "hosts/rockbox/demo.pocket.json"), "utf8"), +); +const nativeHost = readFileSync(join(root, "hosts/rockbox/main.c"), "utf8"); +const runtimePort = readFileSync( + join(root, "hosts/rockbox/runtime_port.c"), + "utf8", +); +const demoMain = readFileSync( + join(root, "hosts/rockbox/demo/main.tsx"), + "utf8", +); +const inputPage = readFileSync( + join(root, "hosts/rockbox/demo/input-test-page.tsx"), + "utf8", +); +const contactsPage = readFileSync( + join(root, "hosts/rockbox/demo/contacts-page.tsx"), + "utf8", +); + +describe("Rockbox iPod classic development profile", () => { + test("resolves the embedded 320x240 demo", () => { + const plan = resolveRockboxBuildPlan(manifest); + expect(plan.target).toEqual({ + id: ROCKBOX_IPOD_CLASSIC_TARGET_ID, + hostAbi: ROCKBOX_IPOD_CLASSIC_HOST_ABI, + }); + expect(plan.viewport.logical).toEqual([320, 240]); + expect(plan.features["input.buttons"]).toBe(true); + expect(plan.features["text.glyphs.baked"]).toBe(true); + }); + + test("rejects a non-native logical viewport", () => { + const changed = structuredClone(manifest); + changed.app.viewport.fixed.logical = [176, 132]; + expect(() => resolveRockboxBuildPlan(changed)).toThrow(); + }); + + test("reserves a 16 MiB runtime stack before the QuickJS heap", () => { + expect(nativeHost).toContain("rb->audio_stop();"); + expect(nativeHost).toContain("rb->plugin_get_audio_buffer(&audio_size)"); + expect(nativeHost).toContain( + "#define POCKETJS_RUNTIME_STACK_SIZE (16u * 1024u * 1024u)", + ); + expect(nativeHost).toContain("heap = audio_buffer + POCKETJS_RUNTIME_STACK_SIZE"); + expect(nativeHost).toContain("rb->create_thread("); + expect(nativeHost).toContain("POCKETJS_RUNTIME_STACK_SIZE,"); + expect(runtimePort).toContain( + "#define POCKET_RUNTIME_JS_STACK_SIZE (8 * 1024 * 1024)", + ); + }); + + test("renders native RGB565 and presents only the damaged LCD region", () => { + expect(nativeHost).toContain("pocket_runtime_render_rgb565("); + expect(nativeHost).toContain("pocket_runtime_damage_bounds(damage)"); + expect(nativeHost).toContain("rb->lcd_bitmap_part("); + expect(nativeHost).toContain("rb->lcd_update_rect("); + expect(nativeHost).not.toContain("rockbox_bgra_to_rgb565("); + expect(nativeHost).not.toContain("rb->lcd_update();"); + }); + + test("ships three hardware-switchable acceptance pages", () => { + expect(demoMain).toContain("const PAGE_COUNT = 3"); + expect(demoMain).toContain("buttons & BTN.CIRCLE"); + expect(demoMain).toContain("pressed & BTN.LEFT"); + expect(demoMain).toContain("pressed & BTN.RIGHT"); + expect(demoMain).toContain(""); + expect(demoMain).toContain(""); + expect(demoMain).toContain(""); + }); + + test("covers every iPod input and virtualizes 10,000 contacts", () => { + for (const button of [ + "BTN.TRIANGLE", + "BTN.LEFT", + "BTN.CIRCLE", + "BTN.RIGHT", + "BTN.START", + "BTN.UP", + "BTN.DOWN", + ]) { + expect(inputPage).toContain(button); + } + expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); + expect(contactsPage).toContain("const CONTACT_WINDOW_ROWS = Math.ceil("); + expect(contactsPage).toContain(""); + expect(contactsPage).toContain("firstIndex() * CONTACT_ROW_HEIGHT"); + expect(contactsPage).not.toContain(""); + const selectionLayer = contactsPage.indexOf('bg-[#2378d4]'); + const textLayer = contactsPage.indexOf(" { + const navigationClass = contactsPage.match( + /function NavigationBar[\s\S]*? `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/rockbox.ts b/tools/rockbox.ts new file mode 100644 index 000000000..9fc5b7029 --- /dev/null +++ b/tools/rockbox.ts @@ -0,0 +1,217 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { + ROCKBOX_IPOD_CLASSIC_TARGET_ID, + resolveRockboxBuildPlan, +} from "./rockbox-profile.ts"; + +const repository = fileURLToPath(new URL("..", import.meta.url)); +const hostDirectory = join(repository, "hosts/rockbox"); +const outputDirectory = join(repository, "dist/rockbox"); +const planPath = join(repository, ".pocket/rockbox-ipod-classic-dev/plan.json"); +const embeddedPath = join(outputDirectory, "generated/app_data.c"); +const defaultManifest = join(hostDirectory, "demo.pocket.json"); +const targetJson = join(hostDirectory, "targets/armv5te-rockbox-eabi.json"); +const coreDirectory = join(repository, "engine/symbian"); +const coreArchive = join( + coreDirectory, + "target/armv5te-rockbox-eabi/release/libpocketjs_symbian_core.a", +); +const quickJsRevision = "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b"; +const quickJsCheckout = join(outputDirectory, "quickjs-rs"); +const quickJsPatch = join(repository, "tools/rockbox/quickjs.patch"); +const command = Bun.argv[2] ?? "doctor"; + +function run(executable: string, args: readonly string[], cwd = repository): void { + const result = Bun.spawnSync({ + cmd: [executable, ...args], + cwd, + stdout: "inherit", + stderr: "inherit", + env: process.env, + }); + if (result.exitCode !== 0) { + throw new Error(`${executable} ${args.join(" ")} failed (${result.exitCode})`); + } +} + +function quickJsSource(): string | undefined { + const configured = process.env.POCKETJS_QUICKJS_DIR; + if (configured && existsSync(join(configured, "quickjs.c"))) return configured; + const nested = join(quickJsCheckout, "libquickjs-sys/embed/quickjs"); + return existsSync(join(nested, "quickjs.c")) ? nested : undefined; +} + +function doctor(): void { + const checks = [ + ["Bun", Bun.which("bun")], + ["Rustup", Bun.which("rustup")], + ["C compiler", Bun.which("cc")], + ["Rockbox ARM compiler", Bun.which("arm-elf-eabi-gcc") ?? Bun.which("arm-none-eabi-gcc")], + ] as const; + for (const [label, path] of checks) { + console.log(`${path ? "[ok]" : "[missing]"} ${label}: ${path ?? "not on PATH"}`); + } + const source = process.env.ROCKBOX_SOURCE; + console.log(`${source && existsSync(join(source, "tools/configure")) ? "[ok]" : "[missing]"} Rockbox source: ${source ?? "set ROCKBOX_SOURCE"}`); + const quickjs = quickJsSource(); + console.log(`${quickjs ? "[ok]" : "[missing]"} pinned QuickJS: ${quickjs ?? "run bun rockbox bootstrap"}`); + if (checks.some(([, path]) => !path) || !source || !quickjs) process.exitCode = 1; +} + +function bootstrap(): void { + if (!existsSync(join(quickJsCheckout, ".git"))) { + mkdirSync(outputDirectory, { recursive: true }); + run("git", ["clone", "--filter=blob:none", "--no-checkout", + "https://github.com/pocket-stack/quickjs-rs.git", quickJsCheckout]); + } + run("git", ["-C", quickJsCheckout, "checkout", "--detach", quickJsRevision]); + const reverse = Bun.spawnSync({ + cmd: ["git", "-C", quickJsCheckout, "apply", "--unidiff-zero", + "--reverse", "--check", quickJsPatch], + stdout: "ignore", + stderr: "ignore", + }); + if (reverse.exitCode !== 0) { + run("git", ["-C", quickJsCheckout, "apply", "--unidiff-zero", quickJsPatch]); + } + console.log(`PocketJS Rockbox: pinned QuickJS ready at ${quickJsCheckout}`); +} + +function currentPlan(manifestPath: string): ResolvedBuildPlan { + return resolveRockboxBuildPlan(JSON.parse(readFileSync(manifestPath, "utf8"))); +} + +function cArray(name: string, bytes: Uint8Array): string { + const rows: string[] = []; + for (let offset = 0; offset < bytes.length; offset += 16) { + rows.push(` ${[...bytes.subarray(offset, offset + 16)] + .map((value) => `0x${value.toString(16).padStart(2, "0")}`).join(", ")},`); + } + return [`const unsigned char ${name}[] = {`, ...rows, "};", + `const unsigned int ${name}_len = ${bytes.length}u;`, ""].join("\n"); +} + +function bundle(manifestPath: string): void { + const plan = currentPlan(manifestPath); + const inputs = extractHostBuildInputs(plan, { + expectedTarget: ROCKBOX_IPOD_CLASSIC_TARGET_ID, + }); + mkdirSync(dirname(planPath), { recursive: true }); + mkdirSync(outputDirectory, { recursive: true }); + mkdirSync(dirname(embeddedPath), { recursive: true }); + writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`); + run(process.execPath, [join(repository, "tools/build.ts"), `--plan=${planPath}`, + `--project-root=${repository}`, `--outdir=${outputDirectory}`]); + const jsPath = join(outputDirectory, `${inputs.appOutput}.js`); + const pakPath = join(outputDirectory, `${inputs.appOutput}.pak`); + if (!existsSync(jsPath) || !existsSync(pakPath)) { + throw new Error("PocketJS compiler did not emit JavaScript and pak artifacts"); + } + writeFileSync(embeddedPath, + cArray("pocket_app_js", readFileSync(jsPath)) + + cArray("pocket_app_pak", readFileSync(pakPath))); + console.log(`PocketJS Rockbox: embedded guest -> ${embeddedPath}`); +} + +function buildCore(): void { + run("cargo", ["build", "--release", "--locked", "--no-default-features", + "--features", "software-only,host-allocator", "--target", targetJson, + "-Z", "json-target-spec", "-Z", "build-std=core,alloc,compiler_builtins", + "-Z", "build-std-features=compiler-builtins-mem"], coreDirectory); + if (!existsSync(coreArchive)) throw new Error(`missing Rust core ${coreArchive}`); +} + +function copySources(stage: string, quickjs: string): void { + rmSync(stage, { recursive: true, force: true }); + mkdirSync(stage, { recursive: true }); + mkdirSync(join(stage, "sys"), { recursive: true }); + for (const name of ["main.c", "input.c", "input.h", "framebuffer.c", "framebuffer.h", + "compat.c", "compat.h", "runtime_port.c", "qjs_config.h", "qjs_quickjs.c", + "qjs_cutils.c", "qjs_libregexp.c", "qjs_libunicode.c", "qjs_dtoa.c", + "pocketjs.make"]) { + copyFileSync(join(hostDirectory, name), join(stage, name)); + } + copyFileSync(join(hostDirectory, "sys/time.h"), join(stage, "sys/time.h")); + for (const name of ["pocket_runtime.c", "pocket_runtime.h", "pocket_spec.h", + "pocket_core.h"]) { + copyFileSync(join(repository, "hosts/iphone2g", name), join(stage, name)); + } + for (const name of ["quickjs.c", "quickjs.h", "quickjs-atom.h", "quickjs-opcode.h", + "cutils.c", "cutils.h", "list.h", "libregexp.c", "libregexp.h", "libregexp-opcode.h", + "libunicode.c", "libunicode.h", "libunicode-table.h", "dtoa.c", "dtoa.h"]) { + copyFileSync(join(quickjs, name), join(stage, name)); + } + copyFileSync(embeddedPath, join(stage, "app_data.c")); + copyFileSync(coreArchive, join(stage, "libpocketjs_rockbox_core.a")); +} + +function configureRockbox(source: string, buildDirectory: string, simulator: boolean): void { + mkdirSync(buildDirectory, { recursive: true }); + if (existsSync(join(buildDirectory, "Makefile"))) return; + const args = ["--target=ipod6g", `--type=${simulator ? "s" : "n"}`, "--no-ccache"]; + if (!simulator && !Bun.which("arm-elf-eabi-gcc") && Bun.which("arm-none-eabi-gcc")) { + args.push("--compiler-prefix=arm-none-eabi-"); + } + run(join(source, "tools/configure"), args, buildDirectory); +} + +function build(manifestPath: string, simulator: boolean): void { + const source = resolve(process.env.ROCKBOX_SOURCE ?? ""); + if (!source || !existsSync(join(source, "tools/configure"))) { + throw new Error("set ROCKBOX_SOURCE to a Rockbox source checkout"); + } + if (!quickJsSource()) bootstrap(); + bundle(manifestPath); + buildCore(); + const quickjs = quickJsSource(); + if (!quickjs) throw new Error("QuickJS bootstrap failed"); + const stage = join(source, "apps/plugins/pocketjs"); + copySources(stage, quickjs); + const buildDirectory = resolve(process.env.ROCKBOX_BUILD ?? + join(outputDirectory, simulator ? "rockbox-sim-build" : "rockbox-ipod6g-build")); + configureRockbox(source, buildDirectory, simulator); + const makeTarget = join(buildDirectory, "apps/plugins/pocketjs/pocketjs.rock"); + run("make", ["-j", String(Math.max(1, navigator.hardwareConcurrency ?? 1)), + "SELECTED_PLUGINS_SRC=", `SELECTED_PLUGINS_SUBDIRS=${stage}`, + makeTarget], buildDirectory); + const artifact = join(buildDirectory, "apps/plugins/pocketjs/pocketjs.rock"); + if (!existsSync(artifact)) throw new Error(`Rockbox did not emit ${artifact}`); + mkdirSync(outputDirectory, { recursive: true }); + const destination = join(outputDirectory, simulator ? "pocketjs-sim.rock" : "pocketjs-ipod6g.rock"); + copyFileSync(artifact, destination); + console.log(`PocketJS Rockbox: ${destination} (${statSync(destination).size} bytes)`); +} + +const manifestArg = Bun.argv.find((value) => value.startsWith("--manifest=")); +const manifestPath = resolve(manifestArg?.slice("--manifest=".length) ?? defaultManifest); +if (command === "doctor") doctor(); +else if (command === "bootstrap") bootstrap(); +else if (command === "bundle") bundle(manifestPath); +else if (command === "build") build(manifestPath, false); +else if (command === "sim") build(manifestPath, true); +else if (command === "test") { + mkdirSync(outputDirectory, { recursive: true }); + run("cc", ["-std=c11", "-Wall", "-Wextra", "-Werror", "-Ihosts/iphone2g", + "hosts/rockbox/framebuffer.c", "hosts/rockbox/input.c", + "hosts/rockbox/tests/platform_test.c", "-o", join(outputDirectory, "platform-test")]); + run(join(outputDirectory, "platform-test"), []); + run("bun", ["test", "tests/rockbox-profile.test.ts"]); +} else if (command === "clean") { + rmSync(join(outputDirectory, "generated"), { recursive: true, force: true }); + rmSync(planPath, { force: true }); +} else { + console.error("usage: bun rockbox [--manifest=path]"); + process.exit(1); +} diff --git a/tools/rockbox/quickjs.patch b/tools/rockbox/quickjs.patch new file mode 100644 index 000000000..051020ca6 --- /dev/null +++ b/tools/rockbox/quickjs.patch @@ -0,0 +1,33 @@ +diff --git a/libquickjs-sys/embed/quickjs/quickjs.c b/libquickjs-sys/embed/quickjs/quickjs.c +index 39e334c..afc8bec 100644 +--- a/libquickjs-sys/embed/quickjs/quickjs.c ++++ b/libquickjs-sys/embed/quickjs/quickjs.c +@@ -100 +100,2 @@ _Static_assert(_Alignof(JSValue) == 8, "Vita JSValue must be 8-byte aligned"); +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || \ ++ defined(POCKETJS_NO_ATOMICS) +@@ -2168 +2169,4 @@ static size_t js_def_malloc_usable_size(const void *ptr) +-#if defined(__APPLE__) ++#if defined(POCKETJS_NO_MALLOC_USABLE_SIZE) ++ (void)ptr; ++ return 0; ++#elif defined(__APPLE__) +@@ -7472 +7476,2 @@ static int find_line_num(JSContext *ctx, JSFunctionBytecode *b, +- int new_line_num, line_num, pc, v, ret, new_col_num, col_num; ++ int new_line_num, line_num, pc, ret, new_col_num, col_num; ++ int32_t v; +@@ -44892 +44897,2 @@ static JSValue js_parseInt(JSContext *ctx, JSValueConst this_val, +- int radix, flags; ++ int32_t radix; ++ int flags; +@@ -47305 +47311,2 @@ static int getTimezoneOffset(int64_t time) +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || \ ++ defined(POCKETJS_FIXED_TIMEZONE) +@@ -53699 +53706 @@ static __exception int remainingElementsCount_add(JSContext *ctx, +- int remainingElementsCount; ++ int32_t remainingElementsCount; +@@ -53730 +53737,2 @@ static JSValue js_promise_all_resolve_element(JSContext *ctx, +- int is_zero, index; ++ int is_zero; ++ int32_t index;