diff --git a/runtimes/native/CMakeLists.txt b/runtimes/native/CMakeLists.txt index cfcbf9ad..e772e4de 100644 --- a/runtimes/native/CMakeLists.txt +++ b/runtimes/native/CMakeLists.txt @@ -120,6 +120,8 @@ endif () add_subdirectory(vendor/cubeb) endif () +find_package(CURL REQUIRED) + file(GLOB COMMON_SOURCES RELATIVE "${CMAKE_SOURCE_DIR}" "src/*.c") # Include a strnlen polyfill for some platforms where it's missing (OSX PPC, maybe others) @@ -170,7 +172,8 @@ target_link_directories(wasm4 PRIVATE $<$:${toywasm_tmp_install}/lib>) endif () -target_link_libraries(wasm4 cubeb +target_include_directories(wasm4 PRIVATE ${CURL_INCLUDE_DIRS}) +target_link_libraries(wasm4 cubeb CURL::libcurl pthread $<$:minifb> $<$:glfw> $<$:toywasm-core>) diff --git a/runtimes/native/src/backend/main.c b/runtimes/native/src/backend/main.c index 665e7b37..44e6c5d8 100644 --- a/runtimes/native/src/backend/main.c +++ b/runtimes/native/src/backend/main.c @@ -6,6 +6,7 @@ #include "../apu.h" #include "../runtime.h" +#include "../store.h" #include "../wasm.h" #include "../window.h" #include "../util.h" @@ -126,38 +127,56 @@ int main (int argc, const char* argv[]) { char* diskPath = NULL; if (argc < 2) { + // Try bundled cart first FILE* file = fopen(argv[0], "rb"); - if (file == NULL) { - goto usage; + if (file != NULL) { + fseek(file, -sizeof(FileFooter), SEEK_END); + FileFooter footer; + if (fread(&footer, 1, sizeof(FileFooter), file) >= sizeof(FileFooter) && footer.magic == 1414676803) { + footer.title[sizeof(footer.title)-1] = '\0'; + title = footer.title; + cartBytes = xmalloc(footer.cartLength); + fseek(file, -sizeof(FileFooter) - footer.cartLength, SEEK_END); + cartLength = fread(cartBytes, 1, footer.cartLength, file); + fclose(file); + diskPath = xmalloc(strlen(argv[0]) + sizeof(DISK_FILE_EXT)); + strcpy(diskPath, argv[0]); +#ifdef _WIN32 + trimFileExtension(diskPath); +#endif + strcat(diskPath, DISK_FILE_EXT); + loadDiskFile(&disk, diskPath); + goto load_cart; + } + fclose(file); } - fseek(file, -sizeof(FileFooter), SEEK_END); - FileFooter footer; - if (fread(&footer, 1, sizeof(FileFooter), file) < sizeof(FileFooter) || footer.magic != 1414676803) { -usage: - // No bundled cart found - fprintf(stderr, "Usage: wasm4 \n"); - return 1; + // No bundled cart — launch store with minimal dummy cart + { + // Minimal WASM module: imports env.memory, exports empty start+update + static const uint8_t dummyCart[] = { + 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x04,0x01,0x60,0x00,0x00,0x02,0x0f, + 0x01,0x03,0x65,0x6e,0x76,0x06,0x6d,0x65,0x6d,0x6f,0x72,0x79,0x02,0x00,0x01,0x03, + 0x03,0x02,0x00,0x00,0x07,0x12,0x02,0x05,0x73,0x74,0x61,0x72,0x74,0x00,0x00,0x06, + 0x75,0x70,0x64,0x61,0x74,0x65,0x00,0x01,0x0a,0x07,0x02,0x02,0x00,0x0b,0x02,0x00, + 0x0b + }; + audioInit(); + w4_storeInit(); + cartBytes = xmalloc(sizeof(dummyCart)); + memcpy(cartBytes, dummyCart, sizeof(dummyCart)); + cartLength = sizeof(dummyCart); + + uint8_t* memory = w4_wasmInit(); + w4_runtimeInit(memory, &disk); + w4_wasmLoadModule(cartBytes, cartLength); + w4_storeOpen(); + w4_windowSetStoreMode(true); + w4_windowBoot(title); + audioUninit(); + return 0; } - // Make sure the title is null terminated - footer.title[sizeof(footer.title)-1] = '\0'; - title = footer.title; - - cartBytes = xmalloc(footer.cartLength); - fseek(file, -sizeof(FileFooter) - footer.cartLength, SEEK_END); - cartLength = fread(cartBytes, 1, footer.cartLength, file); - fclose(file); - - // Look for disk file - diskPath = xmalloc(strlen(argv[0]) + sizeof(DISK_FILE_EXT)); - strcpy(diskPath, argv[0]); -#ifdef _WIN32 - trimFileExtension(diskPath); // Trim .exe on Windows -#endif - strcat(diskPath, DISK_FILE_EXT); - loadDiskFile(&disk, diskPath); - } else if (!strcmp(argv[1], "-") || !strcmp(argv[1], "/dev/stdin")) { size_t bufsize = 1024; cartBytes = xmalloc(bufsize); @@ -205,7 +224,9 @@ int main (int argc, const char* argv[]) { loadDiskFile(&disk, diskPath); } +load_cart: audioInit(); + w4_storeInit(); uint8_t* memory = w4_wasmInit(); w4_runtimeInit(memory, &disk); diff --git a/runtimes/native/src/backend/wasm_wasm3.c b/runtimes/native/src/backend/wasm_wasm3.c index 70aeac0e..5931b0c2 100644 --- a/runtimes/native/src/backend/wasm_wasm3.c +++ b/runtimes/native/src/backend/wasm_wasm3.c @@ -155,13 +155,35 @@ static m3ApiRawFunction (tracef) { m3ApiSuccess(); } +static bool storeLoaded = false; +static bool cartCrashed = false; + static void check (M3Result result) { if (result != m3Err_none) { M3ErrorInfo info; m3_GetErrorInfo(runtime, &info); - fprintf(stderr, "WASM error: %s (%s)\n", result, info.message); - exit(1); + if (storeLoaded) { + fprintf(stderr, "WASM warning: %s (%s)\n", result, info.message); + update = NULL; + start = NULL; + cartCrashed = true; + } else { + fprintf(stderr, "WASM error: %s (%s)\n", result, info.message); + exit(1); + } + } +} + +bool w4_wasmDidCrash(void) { + if (cartCrashed) { + cartCrashed = false; + return true; } + return false; +} + +void w4_wasmSetStoreLoaded(bool value) { + storeLoaded = value; } uint8_t* w4_wasmInit () { @@ -187,6 +209,15 @@ uint8_t* w4_wasmInit () { void w4_wasmDestroy () { m3_FreeRuntime(runtime); m3_FreeEnvironment(env); + env = NULL; + runtime = NULL; + module = NULL; + start = NULL; + update = NULL; +} + +uint8_t* w4_wasmGetMemory () { + return m3_GetMemory(runtime, NULL, 0); } void w4_wasmLoadModule (const uint8_t* wasmBuffer, int byteLength) { @@ -245,6 +276,70 @@ void w4_wasmLoadModule (const uint8_t* wasmBuffer, int byteLength) { } } +int w4_wasmLoadModuleSafe (const uint8_t* wasmBuffer, int byteLength) { + M3Result result; + storeLoaded = true; + + result = m3_ParseModule(env, &module, wasmBuffer, byteLength); + if (result) { + fprintf(stderr, "WASM parse error: %s\n", result); + return -1; + } + + module->memoryImported = true; + + result = m3_LoadModule(runtime, module); + if (result) { + fprintf(stderr, "WASM load error: %s\n", result); + return -1; + } + + m3_LinkRawFunction(module, "env", "blit", "v(iiiiii)", blit); + m3_LinkRawFunction(module, "env", "blitSub", "v(iiiiiiiii)", blitSub); + m3_LinkRawFunction(module, "env", "line", "v(iiii)", line); + m3_LinkRawFunction(module, "env", "hline", "v(iii)", hline); + m3_LinkRawFunction(module, "env", "vline", "v(iii)", vline); + m3_LinkRawFunction(module, "env", "oval", "v(iiii)", oval); + m3_LinkRawFunction(module, "env", "rect", "v(iiii)", rect); + m3_LinkRawFunction(module, "env", "text", "v(iii)", text); + m3_LinkRawFunction(module, "env", "textUtf8", "v(iiii)", textUtf8); + m3_LinkRawFunction(module, "env", "textUtf16", "v(iiii)", textUtf16); + m3_LinkRawFunction(module, "env", "tone", "v(iiii)", tone); + m3_LinkRawFunction(module, "env", "diskr", "i(ii)", diskr); + m3_LinkRawFunction(module, "env", "diskw", "i(ii)", diskw); + m3_LinkRawFunction(module, "env", "trace", "v(i)", trace); + m3_LinkRawFunction(module, "env", "traceUtf8", "v(ii)", traceUtf8); + m3_LinkRawFunction(module, "env", "traceUtf16", "v(ii)", traceUtf16); + m3_LinkRawFunction(module, "env", "tracef", "v(ii)", tracef); + + m3_FindFunction(&start, runtime, "start"); + m3_FindFunction(&update, runtime, "update"); + + result = m3_RunStart(module); + if (result) { + fprintf(stderr, "WASM start error: %s\n", result); + return -1; + } + + M3Function* func; + m3_FindFunction(&func, runtime, "_start"); + if (func) { + result = m3_CallV(func); + if (result) { + fprintf(stderr, "WASM _start warning: %s (ignored)\n", result); + } + } + m3_FindFunction(&func, runtime, "_initialize"); + if (func) { + result = m3_CallV(func); + if (result) { + fprintf(stderr, "WASM _initialize warning: %s (ignored)\n", result); + } + } + + return 0; +} + void w4_wasmCallStart () { if (start) { check(m3_CallV(start)); diff --git a/runtimes/native/src/backend/window_glfw.c b/runtimes/native/src/backend/window_glfw.c index f1a23130..f08ee4d9 100644 --- a/runtimes/native/src/backend/window_glfw.c +++ b/runtimes/native/src/backend/window_glfw.c @@ -8,6 +8,9 @@ #include "../window.h" #include "../runtime.h" +#include "../wasm.h" +#include "../menu.h" +#include "../store.h" static uint32_t table[256]; static GLuint paletteLocation; @@ -24,6 +27,11 @@ static int viewportY; static int viewportSize; static bool should_close = false; +static bool storeMode = false; // true when launched without a cart + +void w4_windowSetStoreMode (bool enabled) { + storeMode = enabled; +} static void initLookupTable () { // Create a lookup table for each byte mapping to 4 bytes: @@ -183,8 +191,32 @@ static void update (GLFWwindow* window) { w4_runtimeSetGamepad(0, gamepad); if (glfwGetKey(window, GLFW_KEY_ESCAPE)) { - should_close = true; + if (w4_storeIsOpen()) { + if (storeMode) { + should_close = true; + } else { + w4_storeClose(); + } + } else if (w4_menuIsOpen()) { + w4_menuClose(); + } else { + should_close = true; + } + } + + // Enter toggles pause menu + static int enterWasPressed = 0; + int enterPressed = glfwGetKey(window, GLFW_KEY_ENTER); + if (enterPressed && !enterWasPressed) { + if (w4_storeIsOpen()) { + // ignore Enter in store + } else if (w4_menuIsOpen()) { + w4_menuClose(); + } else { + w4_menuOpen(); + } } + enterWasPressed = enterPressed; // Mouse handling double mouseX, mouseY; @@ -201,7 +233,52 @@ static void update (GLFWwindow* window) { } w4_runtimeSetMouse(160*(mouseX-contentX)/contentSizeX, 160*(mouseY-contentY)/contentSizeY, mouseButtons); - w4_runtimeUpdate(); + if (w4_storeIsOpen()) { + w4_storeInput(gamepad); + + // Store was closed via Z button without selecting a cart + if (!w4_storeIsOpen() && storeMode) { + should_close = true; + return; + } + + // Check if a cart was downloaded + int cartLen = 0; + uint8_t* cartData = w4_storeGetSelectedCart(&cartLen); + if (cartData) { + w4_storeJoinThread(); + fprintf(stderr, "[store] Loading cart (%d bytes)\n", cartLen); + w4_wasmDestroy(); + uint8_t* mem = w4_wasmInit(); + static w4_Disk storeDisk = {0}; + storeDisk.size = 0; + w4_runtimeInit(mem, &storeDisk); + w4_wasmSetStoreLoaded(true); + w4_wasmLoadModule(cartData, cartLen); + // Note: cartData must NOT be freed — wasm3 holds pointers into it + // Refresh memory pointer — wasm3 may realloc during module load + w4_runtimeSetMemory(w4_wasmGetMemory()); + storeMode = false; + } + } else if (w4_menuIsOpen()) { + w4_menuInput(gamepad); + int action = w4_menuGetAction(); + switch (action) { + case MENU_ACTION_CONTINUE: + w4_menuClose(); + break; + case MENU_ACTION_STORE: + w4_menuClose(); + w4_storeOpen(); + break; + } + } else { + w4_runtimeUpdate(); + if (w4_wasmDidCrash()) { + fprintf(stderr, "[store] Cart crashed, opening store\n"); + w4_storeOpen(); + } + } } void w4_windowBoot (const char* title) { @@ -242,6 +319,19 @@ void w4_windowBoot (const char* title) { } update(window); + + if (w4_storeIsOpen()) { + static uint32_t storePalette[4]; + static uint8_t storeFb[160*160/4]; + w4_storeRender(storePalette, storeFb); + w4_windowComposite(storePalette, storeFb); + } else if (w4_menuIsOpen()) { + static uint32_t menuPalette[4]; + static uint8_t menuFb[160*160/4]; + w4_menuRender(menuPalette, menuFb); + w4_windowComposite(menuPalette, menuFb); + } + glfwSwapBuffers(window); glfwPollEvents(); diff --git a/runtimes/native/src/menu.c b/runtimes/native/src/menu.c new file mode 100644 index 00000000..3269073b --- /dev/null +++ b/runtimes/native/src/menu.c @@ -0,0 +1,199 @@ +#include "menu.h" + +#include +#include "runtime.h" + +#define SCREEN_W 160 +#define SCREEN_H 160 +#define NUM_OPTIONS 2 + +static const char* options[NUM_OPTIONS] = { + "CONTINUE", + "STORE", +}; + +static const int actions[NUM_OPTIONS] = { + MENU_ACTION_CONTINUE, + MENU_ACTION_STORE, +}; + +static bool menuOpen = false; +static int selectedIdx = 0; +static uint8_t lastGamepad = 0; +static int pendingAction = MENU_ACTION_NONE; + +static uint8_t menuFb[SCREEN_W * SCREEN_H / 4]; + +static void setPixel(int x, int y, uint8_t color) { + if (x < 0 || x >= SCREEN_W || y < 0 || y >= SCREEN_H) return; + int idx = (y * SCREEN_W + x) >> 2; + int shift = (x & 3) * 2; + menuFb[idx] = (menuFb[idx] & ~(3 << shift)) | ((color & 3) << shift); +} + +static void drawRect(int x, int y, int w, int h, uint8_t color) { + for (int dy = 0; dy < h; dy++) + for (int dx = 0; dx < w; dx++) + setPixel(x + dx, y + dy, color); +} + +// Same mini font as store.c +static const uint8_t miniFont[][6] = { + {0x0,0x0,0x0,0x0,0x0,0x0}, // space + {0x6,0x9,0x9,0xF,0x9,0x9}, // A + {0x7,0x9,0x7,0x9,0x9,0x7}, // B + {0x6,0x9,0x1,0x1,0x9,0x6}, // C + {0x7,0x9,0x9,0x9,0x9,0x7}, // D + {0xF,0x1,0x7,0x1,0x1,0xF}, // E + {0xF,0x1,0x7,0x1,0x1,0x1}, // F + {0x6,0x9,0x1,0xD,0x9,0x6}, // G + {0x9,0x9,0xF,0x9,0x9,0x9}, // H + {0xE,0x4,0x4,0x4,0x4,0xE}, // I + {0x8,0x8,0x8,0x8,0x9,0x6}, // J + {0x9,0x5,0x3,0x3,0x5,0x9}, // K + {0x1,0x1,0x1,0x1,0x1,0xF}, // L + {0x9,0xF,0xF,0x9,0x9,0x9}, // M + {0x9,0xB,0xD,0x9,0x9,0x9}, // N + {0x6,0x9,0x9,0x9,0x9,0x6}, // O + {0x7,0x9,0x9,0x7,0x1,0x1}, // P + {0x6,0x9,0x9,0x9,0x5,0xA}, // Q + {0x7,0x9,0x9,0x7,0x5,0x9}, // R + {0x6,0x1,0x6,0x8,0x8,0x7}, // S + {0xE,0x4,0x4,0x4,0x4,0x4}, // T + {0x9,0x9,0x9,0x9,0x9,0x6}, // U + {0x9,0x9,0x9,0x9,0x6,0x6}, // V + {0x9,0x9,0x9,0xF,0xF,0x9}, // W + {0x9,0x9,0x6,0x6,0x9,0x9}, // X + {0x9,0x9,0x6,0x4,0x4,0x4}, // Y + {0xF,0x8,0x4,0x2,0x1,0xF}, // Z + {0x6,0x9,0xD,0xB,0x9,0x6}, // 0 + {0x2,0x3,0x2,0x2,0x2,0x7}, // 1 + {0x6,0x9,0x8,0x4,0x2,0xF}, // 2 + {0xF,0x8,0x4,0x8,0x9,0x6}, // 3 + {0x8,0x9,0x9,0xF,0x8,0x8}, // 4 + {0xF,0x1,0x7,0x8,0x9,0x6}, // 5 + {0x4,0x2,0x7,0x9,0x9,0x6}, // 6 + {0xF,0x8,0x4,0x4,0x2,0x2}, // 7 + {0x6,0x9,0x6,0x9,0x9,0x6}, // 8 + {0x6,0x9,0x9,0xE,0x4,0x2}, // 9 + {0x0,0x0,0x0,0x0,0x0,0x2}, // . + {0x0,0x0,0x6,0x0,0x0,0x0}, // - + {0x8,0x8,0x4,0x2,0x1,0x1}, // / + {0x1,0x2,0x4,0x2,0x1,0x0}, // > + {0x0,0x2,0x0,0x0,0x2,0x0}, // : +}; + +static int fontIndex(char c) { + if (c == ' ') return 0; + if (c >= 'A' && c <= 'Z') return 1 + (c - 'A'); + if (c >= 'a' && c <= 'z') return 1 + (c - 'a'); + if (c >= '0' && c <= '9') return 27 + (c - '0'); + if (c == '.') return 37; + if (c == '-') return 38; + if (c == '/') return 39; + if (c == '>') return 40; + if (c == ':') return 41; + return 0; +} + +static void drawChar(int x, int y, char c, uint8_t color) { + int idx = fontIndex(c); + for (int row = 0; row < 6; row++) { + uint8_t bits = miniFont[idx][row]; + for (int col = 0; col < 4; col++) { + if (bits & (1 << col)) + setPixel(x + col, y + row, color); + } + } +} + +static void drawText(int x, int y, const char* text, uint8_t color) { + while (*text) { + drawChar(x, y, *text, color); + x += 5; + text++; + } +} + +static int textWidth(const char* text) { + int len = strlen(text); + return len > 0 ? len * 5 - 1 : 0; +} + +static void drawTextCentered(int y, const char* text, uint8_t color) { + int w = textWidth(text); + drawText((SCREEN_W - w) / 2, y, text, color); +} + +void w4_menuOpen(void) { + if (menuOpen) return; + menuOpen = true; + selectedIdx = 0; + lastGamepad = 0; + pendingAction = MENU_ACTION_NONE; +} + +void w4_menuClose(void) { + menuOpen = false; + pendingAction = MENU_ACTION_NONE; +} + +bool w4_menuIsOpen(void) { + return menuOpen; +} + +void w4_menuInput(uint8_t gamepad) { + uint8_t pressed = gamepad & (gamepad ^ lastGamepad); + lastGamepad = gamepad; + + if (pressed & W4_BUTTON_DOWN) { + selectedIdx = (selectedIdx + 1) % NUM_OPTIONS; + } + if (pressed & W4_BUTTON_UP) { + selectedIdx = (selectedIdx + NUM_OPTIONS - 1) % NUM_OPTIONS; + } + if (pressed & (W4_BUTTON_X | W4_BUTTON_Z)) { + pendingAction = actions[selectedIdx]; + } +} + +int w4_menuGetAction(void) { + int action = pendingAction; + pendingAction = MENU_ACTION_NONE; + return action; +} + +void w4_menuRender(uint32_t* palette, uint8_t* framebuffer) { + palette[0] = 0x111111; + palette[1] = 0x333333; + palette[2] = 0x73eff7; + palette[3] = 0xf4f4f4; + + memset(menuFb, 0x00, sizeof(menuFb)); + + // Title + drawTextCentered(30, "WASM-4", 2); + + // Menu border + int menuX = 30; + int menuY = 48; + int menuW = 100; + int menuH = NUM_OPTIONS * 14 + 8; + drawRect(menuX, menuY, menuW, menuH, 1); + drawRect(menuX + 1, menuY + 1, menuW - 2, menuH - 2, 0); + + // Options + for (int i = 0; i < NUM_OPTIONS; i++) { + int y = menuY + 6 + i * 14; + + if (i == selectedIdx) { + drawRect(menuX + 2, y - 2, menuW - 4, 12, 1); + drawText(menuX + 8, y, ">", 2); + drawText(menuX + 16, y, options[i], 2); + } else { + drawText(menuX + 16, y, options[i], 3); + } + } + + memcpy(framebuffer, menuFb, sizeof(menuFb)); +} diff --git a/runtimes/native/src/menu.h b/runtimes/native/src/menu.h new file mode 100644 index 00000000..0431e41b --- /dev/null +++ b/runtimes/native/src/menu.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +void w4_menuOpen(void); +void w4_menuClose(void); +bool w4_menuIsOpen(void); +void w4_menuInput(uint8_t gamepad); +void w4_menuRender(uint32_t* palette, uint8_t* framebuffer); + +// Returns the selected action (0 = none) +#define MENU_ACTION_NONE 0 +#define MENU_ACTION_CONTINUE 1 +#define MENU_ACTION_STORE 2 + +int w4_menuGetAction(void); diff --git a/runtimes/native/src/runtime.c b/runtimes/native/src/runtime.c index 22ee1e6f..851058a6 100644 --- a/runtimes/native/src/runtime.c +++ b/runtimes/native/src/runtime.c @@ -87,6 +87,10 @@ static void bounds_check_cstr(const char *p) } } +void w4_runtimeSetMemory (uint8_t* memoryBytes) { + memory = (Memory*)memoryBytes; +} + void w4_runtimeInit (uint8_t* memoryBytes, w4_Disk* diskBytes) { memory = (Memory*)memoryBytes; disk = diskBytes; diff --git a/runtimes/native/src/runtime.h b/runtimes/native/src/runtime.h index 1a7084da..5ec0c747 100644 --- a/runtimes/native/src/runtime.h +++ b/runtimes/native/src/runtime.h @@ -21,6 +21,7 @@ typedef struct { } w4_Disk; void w4_runtimeInit (uint8_t* memory, w4_Disk* disk); +void w4_runtimeSetMemory (uint8_t* memory); void w4_runtimeSetGamepad (int idx, uint8_t gamepad); void w4_runtimeSetMouse (int16_t x, int16_t y, uint8_t buttons); diff --git a/runtimes/native/src/store.c b/runtimes/native/src/store.c new file mode 100644 index 00000000..309fcdd5 --- /dev/null +++ b/runtimes/native/src/store.c @@ -0,0 +1,621 @@ +#include "store.h" + +#include +#include +#include +#include +#include + +#include "runtime.h" + +#define STORE_URL "https://wasm4.org/carts.json" +#define CART_BASE_URL "https://wasm4.org/carts/" +#define MAX_CARTS 512 +#define MAX_TITLE 64 +#define MAX_AUTHOR 64 +#define MAX_SLUG 64 +#define ITEMS_PER_PAGE 7 +#define FONT_W 8 +#define FONT_H 8 +#define SCREEN_W 160 +#define SCREEN_H 160 + +typedef struct { + char slug[MAX_SLUG]; + char title[MAX_TITLE]; + char author[MAX_AUTHOR]; +} CartEntry; + +typedef enum { + STORE_CLOSED, + STORE_LOADING_CATALOG, + STORE_BROWSING, + STORE_DOWNLOADING, + STORE_ERROR +} StoreState; + +static StoreState state = STORE_CLOSED; +static CartEntry carts[MAX_CARTS]; +static int cartCount = 0; +static int selectedIdx = 0; +static int scrollOffset = 0; +static uint8_t lastGamepad = 0; +static char errorMsg[64] = {0}; + +// Downloaded cart data +static uint8_t* downloadedCart = NULL; +static int downloadedCartLen = 0; +static bool downloadReady = false; + +// Thread for async downloads +static pthread_t downloadThread; +static bool threadActive = false; + +// ---- CURL helpers ---- + +typedef struct { + char* data; + size_t size; +} CurlBuffer; + +static size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t realsize = size * nmemb; + CurlBuffer* buf = (CurlBuffer*)userp; + char* ptr = realloc(buf->data, buf->size + realsize + 1); + if (!ptr) return 0; + buf->data = ptr; + memcpy(&(buf->data[buf->size]), contents, realsize); + buf->size += realsize; + buf->data[buf->size] = 0; + return realsize; +} + +static CurlBuffer curlFetch(const char* url) { + CurlBuffer buf = {0}; + buf.data = malloc(1); + buf.size = 0; + + CURL* curl = curl_easy_init(); + if (curl) { + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)&buf); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + free(buf.data); + buf.data = NULL; + buf.size = 0; + } + curl_easy_cleanup(curl); + } + return buf; +} + +// ---- Minimal JSON parsing for carts.json ---- +// Format: [{"slug":"x","title":"X","authors":[{"name":"Y"}],...}, ...] + +static const char* skipWhitespace(const char* p) { + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; + return p; +} + +static const char* parseString(const char* p, char* out, int maxLen) { + if (*p != '"') return NULL; + p++; + int i = 0; + while (*p && *p != '"') { + if (*p == '\\') { + p++; + if (!*p) return NULL; + } + if (i < maxLen - 1) out[i++] = *p; + p++; + } + out[i] = 0; + if (*p == '"') p++; + return p; +} + +static const char* skipValue(const char* p) { + p = skipWhitespace(p); + if (*p == '"') { + p++; + while (*p && *p != '"') { + if (*p == '\\') p++; + p++; + } + if (*p == '"') p++; + } else if (*p == '[' || *p == '{') { + char open = *p, close = (*p == '[') ? ']' : '}'; + int depth = 1; + p++; + while (*p && depth > 0) { + if (*p == '"') { + p++; + while (*p && *p != '"') { if (*p == '\\') p++; p++; } + if (*p == '"') p++; + continue; + } + if (*p == open) depth++; + else if (*p == close) depth--; + p++; + } + } else { + while (*p && *p != ',' && *p != '}' && *p != ']') p++; + } + return p; +} + +static void parseCatalog(const char* json) { + cartCount = 0; + const char* p = skipWhitespace(json); + if (*p != '[') return; + p++; + + while (cartCount < MAX_CARTS) { + p = skipWhitespace(p); + if (*p == ']' || !*p) break; + if (*p == ',') { p++; continue; } + if (*p != '{') break; + p++; + + CartEntry entry = {0}; + + // Parse object fields + while (*p && *p != '}') { + p = skipWhitespace(p); + if (*p == ',') { p++; continue; } + if (*p == '}') break; + + char key[32] = {0}; + const char* pnext = parseString(p, key, sizeof(key)); + if (!pnext) { p = skipValue(p); if (!p) break; continue; } + p = pnext; + p = skipWhitespace(p); + if (*p == ':') p++; + p = skipWhitespace(p); + + if (strcmp(key, "slug") == 0) { + pnext = parseString(p, entry.slug, MAX_SLUG); + if (!pnext) { p = skipValue(p); if (!p) break; continue; } + p = pnext; + } else if (strcmp(key, "title") == 0) { + pnext = parseString(p, entry.title, MAX_TITLE); + if (!pnext) { p = skipValue(p); if (!p) break; continue; } + p = pnext; + } else if (strcmp(key, "authors") == 0) { + // Parse first author name from array — skip entire value safely + const char* authorsStart = p; + p = skipValue(p); + // Now re-parse just to extract first author name + { + const char* a = authorsStart; + if (*a == '[') { + a++; + while (*a == ' ' || *a == '\n' || *a == '\r' || *a == '\t') a++; + if (*a == '{') { + a++; + while (*a && *a != '}') { + while (*a == ' ' || *a == '\n' || *a == '\r' || *a == '\t' || *a == ',') a++; + if (*a == '}') break; + char akey[32] = {0}; + const char* anext = parseString(a, akey, sizeof(akey)); + if (!anext) break; + a = anext; + while (*a == ' ' || *a == '\n' || *a == '\r' || *a == '\t') a++; + if (*a == ':') a++; + while (*a == ' ' || *a == '\n' || *a == '\r' || *a == '\t') a++; + if (strcmp(akey, "name") == 0) { + parseString(a, entry.author, MAX_AUTHOR); + break; // Got the name, done + } else { + a = skipValue(a); + } + } + } + } + } + } else { + p = skipValue(p); + } + if (!p) break; + } + if (!p || !*p) break; + if (*p == '}') p++; + + if (entry.slug[0] && entry.title[0]) { + carts[cartCount++] = entry; + } + } +} + +// ---- Async download threads ---- + +static void* catalogDownloadThread(void* arg) { + (void)arg; + CurlBuffer buf = curlFetch(STORE_URL); + if (buf.data && buf.size > 0) { + parseCatalog(buf.data); + free(buf.data); + if (cartCount > 0) { + state = STORE_BROWSING; + } else { + snprintf(errorMsg, sizeof(errorMsg), "NO GAMES FOUND"); + state = STORE_ERROR; + } + } else { + snprintf(errorMsg, sizeof(errorMsg), "DOWNLOAD FAILED"); + state = STORE_ERROR; + } + threadActive = false; + return NULL; +} + +static void* cartDownloadThread(void* arg) { + char* slug = (char*)arg; + char url[256]; + snprintf(url, sizeof(url), "%s%s.wasm", CART_BASE_URL, slug); + fprintf(stderr, "[store] Downloading: %s\n", url); + free(slug); + + CurlBuffer buf = curlFetch(url); + if (buf.data && buf.size > 0) { + fprintf(stderr, "[store] Downloaded %zu bytes\n", buf.size); + downloadedCart = (uint8_t*)buf.data; + downloadedCartLen = (int)buf.size; + downloadReady = true; + } else { + fprintf(stderr, "[store] Download failed\n"); + snprintf(errorMsg, sizeof(errorMsg), "CART DOWNLOAD FAILED"); + state = STORE_ERROR; + } + threadActive = false; + return NULL; +} + +// ---- Framebuffer text rendering ---- +// We render directly into a 160x160 2bpp framebuffer using the WASM-4 font + +// WASM-4 built-in font data (1bpp, 8x8, ASCII 32-255) +// We reuse the font from constants — but since we're in C, we just call +// the framebuffer text function + +static uint8_t storeFb[SCREEN_W * SCREEN_H / 4]; +static uint8_t storeDrawColors[2]; + +static void storeClearFb(void) { + // Fill with color 0 (palette index 0 = darkest background) + memset(storeFb, 0x00, sizeof(storeFb)); +} + +static void storeSetPixel(int x, int y, uint8_t color) { + if (x < 0 || x >= SCREEN_W || y < 0 || y >= SCREEN_H) return; + int idx = (y * SCREEN_W + x) >> 2; + int shift = (x & 3) * 2; + storeFb[idx] = (storeFb[idx] & ~(3 << shift)) | ((color & 3) << shift); +} + +static void storeDrawRect(int x, int y, int w, int h, uint8_t color) { + for (int dy = 0; dy < h; dy++) { + for (int dx = 0; dx < w; dx++) { + storeSetPixel(x + dx, y + dy, color); + } + } +} + +// Minimal 4x6 font for store UI (uppercase + digits + some symbols) +// Each character is 4 pixels wide, 6 pixels tall, stored as 6 bytes (4 bits used per row) +static const uint8_t miniFont[][6] = { + // ' ' (space) + {0x0,0x0,0x0,0x0,0x0,0x0}, + // A + {0x6,0x9,0x9,0xF,0x9,0x9}, + // B + {0x7,0x9,0x7,0x9,0x9,0x7}, + // C + {0x6,0x9,0x1,0x1,0x9,0x6}, + // D + {0x7,0x9,0x9,0x9,0x9,0x7}, + // E + {0xF,0x1,0x7,0x1,0x1,0xF}, + // F + {0xF,0x1,0x7,0x1,0x1,0x1}, + // G + {0x6,0x9,0x1,0xD,0x9,0x6}, + // H + {0x9,0x9,0xF,0x9,0x9,0x9}, + // I + {0xE,0x4,0x4,0x4,0x4,0xE}, + // J + {0x8,0x8,0x8,0x8,0x9,0x6}, + // K + {0x9,0x5,0x3,0x3,0x5,0x9}, + // L + {0x1,0x1,0x1,0x1,0x1,0xF}, + // M + {0x9,0xF,0xF,0x9,0x9,0x9}, + // N + {0x9,0xB,0xD,0x9,0x9,0x9}, + // O + {0x6,0x9,0x9,0x9,0x9,0x6}, + // P + {0x7,0x9,0x9,0x7,0x1,0x1}, + // Q + {0x6,0x9,0x9,0x9,0x5,0xA}, + // R + {0x7,0x9,0x9,0x7,0x5,0x9}, + // S + {0x6,0x1,0x6,0x8,0x8,0x7}, + // T + {0xE,0x4,0x4,0x4,0x4,0x4}, + // U + {0x9,0x9,0x9,0x9,0x9,0x6}, + // V + {0x9,0x9,0x9,0x9,0x6,0x6}, + // W + {0x9,0x9,0x9,0xF,0xF,0x9}, + // X + {0x9,0x9,0x6,0x6,0x9,0x9}, + // Y + {0x9,0x9,0x6,0x4,0x4,0x4}, + // Z + {0xF,0x8,0x4,0x2,0x1,0xF}, + // 0 + {0x6,0x9,0xD,0xB,0x9,0x6}, + // 1 + {0x2,0x3,0x2,0x2,0x2,0x7}, + // 2 + {0x6,0x9,0x8,0x4,0x2,0xF}, + // 3 + {0xF,0x8,0x4,0x8,0x9,0x6}, + // 4 + {0x8,0x9,0x9,0xF,0x8,0x8}, + // 5 + {0xF,0x1,0x7,0x8,0x9,0x6}, + // 6 + {0x4,0x2,0x7,0x9,0x9,0x6}, + // 7 + {0xF,0x8,0x4,0x4,0x2,0x2}, + // 8 + {0x6,0x9,0x6,0x9,0x9,0x6}, + // 9 + {0x6,0x9,0x9,0xE,0x4,0x2}, + // . (period) + {0x0,0x0,0x0,0x0,0x0,0x2}, + // - (hyphen) + {0x0,0x0,0x6,0x0,0x0,0x0}, + // / (slash) + {0x8,0x8,0x4,0x2,0x1,0x1}, + // > (arrow) + {0x1,0x2,0x4,0x2,0x1,0x0}, + // : (colon) + {0x0,0x2,0x0,0x0,0x2,0x0}, +}; + +static int miniFontIndex(char c) { + if (c == ' ') return 0; + if (c >= 'A' && c <= 'Z') return 1 + (c - 'A'); + if (c >= 'a' && c <= 'z') return 1 + (c - 'a'); + if (c >= '0' && c <= '9') return 27 + (c - '0'); + if (c == '.') return 37; + if (c == '-') return 38; + if (c == '/') return 39; + if (c == '>') return 40; + if (c == ':') return 41; + return 0; // space for unknown +} + +static void storeDrawChar(int x, int y, char c, uint8_t color) { + int idx = miniFontIndex(c); + for (int row = 0; row < 6; row++) { + uint8_t bits = miniFont[idx][row]; + for (int col = 0; col < 4; col++) { + if (bits & (1 << col)) { + storeSetPixel(x + col, y + row, color); + } + } + } +} + +static void storeDrawText(int x, int y, const char* text, uint8_t color) { + while (*text) { + storeDrawChar(x, y, *text, color); + x += 5; + text++; + } +} + +static int storeTextWidth(const char* text) { + int len = strlen(text); + return len > 0 ? len * 5 - 1 : 0; +} + +static void storeDrawTextCentered(int y, const char* text, uint8_t color) { + int w = storeTextWidth(text); + storeDrawText((SCREEN_W - w) / 2, y, text, color); +} + +// ---- Public API ---- + +void w4_storeInit(void) { + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +void w4_storeOpen(void) { + if (state != STORE_CLOSED) return; + + // Keep selectedIdx and scrollOffset from previous session + lastGamepad = 0xff; // ignore all currently held buttons + downloadReady = false; + errorMsg[0] = 0; + + if (cartCount > 0) { + // Already have catalog cached + state = STORE_BROWSING; + } else { + state = STORE_LOADING_CATALOG; + catalogDownloadThread(NULL); + } +} + +void w4_storeClose(void) { + if (state == STORE_LOADING_CATALOG || state == STORE_DOWNLOADING) { + // Let thread finish in background + if (threadActive) { + pthread_detach(downloadThread); + threadActive = false; + } + } + state = STORE_CLOSED; +} + +bool w4_storeIsOpen(void) { + return state != STORE_CLOSED; +} + +void w4_storeInput(uint8_t gamepad) { + uint8_t pressed = gamepad & (gamepad ^ lastGamepad); + lastGamepad = gamepad; + + if (state == STORE_ERROR) { + if (pressed & (W4_BUTTON_Z | W4_BUTTON_X)) { + w4_storeClose(); + } + return; + } + + if (state != STORE_BROWSING) return; + + if (pressed & W4_BUTTON_DOWN) { + selectedIdx++; + if (selectedIdx >= cartCount) selectedIdx = 0; + if (selectedIdx >= scrollOffset + ITEMS_PER_PAGE) { + scrollOffset = selectedIdx - ITEMS_PER_PAGE + 1; + } + if (selectedIdx == 0) scrollOffset = 0; + } + if (pressed & W4_BUTTON_UP) { + selectedIdx--; + if (selectedIdx < 0) selectedIdx = cartCount - 1; + if (selectedIdx < scrollOffset) { + scrollOffset = selectedIdx; + } + if (selectedIdx == cartCount - 1) { + scrollOffset = cartCount - ITEMS_PER_PAGE; + if (scrollOffset < 0) scrollOffset = 0; + } + } + if (pressed & W4_BUTTON_RIGHT) { + selectedIdx += ITEMS_PER_PAGE; + if (selectedIdx >= cartCount) selectedIdx = selectedIdx % cartCount; + scrollOffset = selectedIdx - ITEMS_PER_PAGE + 1; + if (scrollOffset < 0) scrollOffset = 0; + } + if (pressed & W4_BUTTON_LEFT) { + selectedIdx -= ITEMS_PER_PAGE; + if (selectedIdx < 0) selectedIdx = cartCount + selectedIdx; + scrollOffset = selectedIdx; + if (scrollOffset < 0) scrollOffset = 0; + } + + if (pressed & W4_BUTTON_X) { + if (cartCount > 0) { + state = STORE_DOWNLOADING; + threadActive = true; + char* slug = strdup(carts[selectedIdx].slug); + pthread_create(&downloadThread, NULL, cartDownloadThread, slug); + } + } + + if (pressed & W4_BUTTON_Z) { + w4_storeClose(); + } +} + +void w4_storeRender(uint32_t* palette, uint8_t* framebuffer) { + // Store palette: dark background, light text + palette[0] = 0x1a1c2c; // darkest (bg) + palette[1] = 0x5d275d; // dark accent + palette[2] = 0x73eff7; // highlight + palette[3] = 0xf4f4f4; // lightest (text) + + storeClearFb(); + + if (state == STORE_LOADING_CATALOG) { + storeDrawTextCentered(76, "LOADING...", 3); + } + else if (state == STORE_DOWNLOADING) { + storeDrawTextCentered(70, "DOWNLOADING", 3); + storeDrawTextCentered(82, carts[selectedIdx].title, 2); + } + else if (state == STORE_ERROR) { + storeDrawTextCentered(70, errorMsg, 2); + storeDrawTextCentered(86, "PRESS ANY KEY", 3); + } + else if (state == STORE_BROWSING) { + // Header + storeDrawTextCentered(4, "WASM-4 STORE", 2); + + // Divider line + storeDrawRect(4, 14, SCREEN_W - 8, 1, 1); + + // Game list + int y = 18; + int end = scrollOffset + ITEMS_PER_PAGE; + if (end > cartCount) end = cartCount; + + for (int i = scrollOffset; i < end; i++) { + uint8_t titleColor = (i == selectedIdx) ? 2 : 3; + + if (i == selectedIdx) { + // Highlight bar + storeDrawRect(2, y - 1, SCREEN_W - 4, 18, 1); + // Arrow + storeDrawChar(4, y + 1, '>', 2); + } + + // Title (truncate to fit) + char buf[28]; + snprintf(buf, sizeof(buf), "%s", carts[i].title); + storeDrawText(12, y + 1, buf, titleColor); + + // Author below title + char authorBuf[28]; + snprintf(authorBuf, sizeof(authorBuf), "%s", carts[i].author); + storeDrawText(12, y + 9, authorBuf, (i == selectedIdx) ? 3 : 1); + + y += 19; + } + + // Footer with scroll info + storeDrawRect(0, SCREEN_H - 11, SCREEN_W, 11, 1); + char footer[32]; + snprintf(footer, sizeof(footer), "%d/%d", selectedIdx + 1, cartCount); + storeDrawText(4, SCREEN_H - 9, footer, 3); + storeDrawText(80, SCREEN_H - 9, "X:OK Z:BACK", 2); + } + + memcpy(framebuffer, storeFb, sizeof(storeFb)); +} + +void w4_storeJoinThread(void) { + if (threadActive) { + pthread_join(downloadThread, NULL); + threadActive = false; + } +} + +uint8_t* w4_storeGetSelectedCart(int* outLength) { + if (downloadReady && downloadedCart) { + uint8_t* cart = downloadedCart; + *outLength = downloadedCartLen; + downloadedCart = NULL; + downloadedCartLen = 0; + downloadReady = false; + state = STORE_CLOSED; + return cart; + } + return NULL; +} diff --git a/runtimes/native/src/store.h b/runtimes/native/src/store.h new file mode 100644 index 00000000..9f538aaa --- /dev/null +++ b/runtimes/native/src/store.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +// Initialize the store (call once at startup) +void w4_storeInit(void); + +// Open/close the store overlay +void w4_storeOpen(void); +void w4_storeClose(void); + +// Returns true if the store overlay is currently visible +bool w4_storeIsOpen(void); + +// Process gamepad input for the store UI +// Returns true if store consumed the input (game should not process it) +void w4_storeInput(uint8_t gamepad); + +// Render the store overlay to a framebuffer +// palette/framebuffer are used for rendering via w4_windowComposite +void w4_storeRender(uint32_t* palette, uint8_t* framebuffer); + +// If a cart was selected for download, returns the wasm bytes and length +// Caller must free the returned buffer. Returns NULL if no cart ready. +uint8_t* w4_storeGetSelectedCart(int* outLength); + +// Wait for download thread to finish +void w4_storeJoinThread(void); diff --git a/runtimes/native/src/wasm.h b/runtimes/native/src/wasm.h index 21713ce6..d3c0e913 100644 --- a/runtimes/native/src/wasm.h +++ b/runtimes/native/src/wasm.h @@ -4,8 +4,20 @@ uint8_t* w4_wasmInit (); void w4_wasmDestroy (); +uint8_t* w4_wasmGetMemory (); + void w4_wasmLoadModule (const uint8_t* wasmBuffer, int byteLength); +// Same as w4_wasmLoadModule but returns 0 on success, -1 on error (does not exit) +int w4_wasmLoadModuleSafe (const uint8_t* wasmBuffer, int byteLength); + void w4_wasmCallStart (); void w4_wasmCallUpdate (); + + +// Returns true (once) if a store-loaded cart crashed +bool w4_wasmDidCrash(void); + +// Mark that next load is from store (errors won't exit) +void w4_wasmSetStoreLoaded(bool value); diff --git a/runtimes/native/src/window.h b/runtimes/native/src/window.h index 48623878..c9364630 100644 --- a/runtimes/native/src/window.h +++ b/runtimes/native/src/window.h @@ -1,7 +1,9 @@ #pragma once +#include #include void w4_windowBoot (const char* title); +void w4_windowSetStoreMode (bool enabled); void w4_windowComposite (const uint32_t* palette, const uint8_t* framebuffer); diff --git a/runtimes/native/vendor/wasm3 b/runtimes/native/vendor/wasm3 index dc9fa493..985b8757 160000 --- a/runtimes/native/vendor/wasm3 +++ b/runtimes/native/vendor/wasm3 @@ -1 +1 @@ -Subproject commit dc9fa49340b8d066a479cf36f7412208ff91a0ee +Subproject commit 985b8757e34b89e93d64f9433ffcfa991ce155ab