From 0d0c33322e12444b83b5142cd538f0e0e3324f16 Mon Sep 17 00:00:00 2001 From: "Miguel C. Dias" Date: Fri, 10 Jul 2026 22:48:25 -0300 Subject: [PATCH 1/5] Add GitHub Actions workflow for WebAssembly build --- .github/workflows/webassembly.yml | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/webassembly.yml diff --git a/.github/workflows/webassembly.yml b/.github/workflows/webassembly.yml new file mode 100644 index 000000000..14cd633c7 --- /dev/null +++ b/.github/workflows/webassembly.yml @@ -0,0 +1,53 @@ +name: Build Butterscotch WebAssembly + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Emscripten + uses: mymindstorm/setup-emsdk@v14 + with: + version: latest + + - name: Verify + run: | + emcc --version + cmake --version + + - name: Configure + run: | + emcmake cmake \ + -B build-web \ + -DCMAKE_BUILD_TYPE=Release \ + -DPLATFORM=web \ + -DENABLE_MODERN_GL=ON \ + -DENABLE_LEGACY_GL=OFF + + - name: Build + run: | + cmake --build build-web --parallel + + - name: List Output + run: | + ls -R build-web + + - name: Upload WebAssembly + uses: actions/upload-artifact@v4 + with: + name: Butterscotch-WebAssembly + path: | + build-web/**/*.wasm + build-web/**/*.mjs + build-web/**/*.js + build-web/**/*.html + build-web/**/*.data + build-web/**/*.worker.js From a093996e66a1a05044efb3ec5c85b30768e1b33f Mon Sep 17 00:00:00 2001 From: "Miguel C. Dias" Date: Fri, 10 Jul 2026 22:51:02 -0300 Subject: [PATCH 2/5] Refactor WebAssembly build workflow in GitHub Actions --- .github/workflows/webassembly.yml | 58 +++++++++++++------------------ 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/.github/workflows/webassembly.yml b/.github/workflows/webassembly.yml index 14cd633c7..a26ecb075 100644 --- a/.github/workflows/webassembly.yml +++ b/.github/workflows/webassembly.yml @@ -1,53 +1,45 @@ -name: Build Butterscotch WebAssembly +name: Build Web on: - workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] jobs: - build: - runs-on: ubuntu-latest + build-web: + name: Build (Web) + runs-on: ubuntu-24.04 steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive + - uses: actions/checkout@v4 + + - name: Get commit info + id: commit-info + run: | + echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + echo "date=$(git log -1 --format=%cd --date=short)" >> $GITHUB_OUTPUT - - name: Setup Emscripten + - name: Setup Emscripten SDK uses: mymindstorm/setup-emsdk@v14 with: version: latest - - name: Verify - run: | - emcc --version - cmake --version - - name: Configure run: | emcmake cmake \ - -B build-web \ - -DCMAKE_BUILD_TYPE=Release \ + -B build \ + -DWERROR=ON \ -DPLATFORM=web \ - -DENABLE_MODERN_GL=ON \ - -DENABLE_LEGACY_GL=OFF + -DCMAKE_BUILD_TYPE=Release \ + "-DBUTTERSCOTCH_COMMIT_HASH=${{ steps.commit-info.outputs.hash }}" \ + "-DBUTTERSCOTCH_COMMIT_DATE=${{ steps.commit-info.outputs.date }}" - name: Build - run: | - cmake --build build-web --parallel - - - name: List Output - run: | - ls -R build-web + run: cmake --build build -j$(nproc) - - name: Upload WebAssembly + - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: Butterscotch-WebAssembly - path: | - build-web/**/*.wasm - build-web/**/*.mjs - build-web/**/*.js - build-web/**/*.html - build-web/**/*.data - build-web/**/*.worker.js + name: butterscotch-web + path: build/butterscotch.* From df5fcfd687f6741469685a9bc00fa03449f5b032 Mon Sep 17 00:00:00 2001 From: "Miguel C. Dias" Date: Thu, 16 Jul 2026 11:56:19 -0300 Subject: [PATCH 3/5] Refactor main.c for readability and audio handling Refactor main.c to improve readability and maintainability. Changes include initializing gRunner to nullptr, updating comments, and restructuring the audio frame handling. --- src/web/main.c | 320 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 216 insertions(+), 104 deletions(-) diff --git a/src/web/main.c b/src/web/main.c index dd3380e47..f3a03ac3d 100644 --- a/src/web/main.c +++ b/src/web/main.c @@ -6,6 +6,7 @@ #include #include #include + #include "data_win.h" #include "noop_audio_system.h" #include "web_audio_system.h" @@ -15,10 +16,13 @@ #include "gettime.h" static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE ctx = 0; -static Runner* gRunner; +static Runner* gRunner = nullptr; static WebAudioSystem* gWebAudio = nullptr; static int32_t gAudioSampleRate = 48000; +static double gLastFrameStartMs = 0.0; +static bool gFrameLoopActive = false; + uint8_t keyDown[GML_KEY_COUNT] = {0}; uint8_t keyUp[GML_KEY_COUNT] = {0}; @@ -29,14 +33,15 @@ void setAudioSampleRate(int32_t rate) { } // Pulls frameCount interleaved-stereo float32 frames into outPtr (which must point into wasm memory). -// Called from JS by the worker's audio pull loop. Safe to call before the runner starts (returns silence). +// Called from JS by the audio pull loop. Safe to call before the runner starts (returns silence). void pullAudioFrames(float* outPtr, int32_t frameCount) { if (gWebAudio == nullptr || frameCount <= 0) { if (outPtr != nullptr && frameCount > 0) { - memset(outPtr, 0, (size_t) frameCount * 2 * sizeof(float)); + memset(outPtr, 0, (size_t)frameCount * 2 * sizeof(float)); } return; } + WebAudioSystem_pullFrames(gWebAudio, outPtr, frameCount); } @@ -52,9 +57,31 @@ int getKeyCount() { return GML_KEY_COUNT; } -int main() { +static void postHostMessage(const char* type, const char* title) { + EM_ASM({ + const payload = { type: UTF8ToString($0) }; + + if ($1) { + payload.title = UTF8ToString($1); + } + + try { + if (typeof window !== 'undefined' && + window.parent && + window.parent !== window && + typeof window.parent.postMessage === 'function') { + window.parent.postMessage(payload, '*'); + } else if (typeof self !== 'undefined' && typeof self.postMessage === 'function') { + self.postMessage(payload); + } else if (typeof postMessage === 'function') { + try { postMessage(payload); } catch (_) {} + } + } catch (_) {} + }, type, title); +} + +int main(void) { printf("Howdy! Loritta is so cute! lol\n"); - emscripten_exit_with_live_runtime(); return 0; } @@ -65,11 +92,13 @@ int mountOpfs(void) { fprintf(stderr, "Failed to create OPFS backend\n"); return -1; } + int rc = wasmfs_create_directory("/butterscotch", 0777, opfs); if (rc != 0) { fprintf(stderr, "Failed to mount OPFS at /butterscotch: %s\n", strerror(errno)); return -1; } + return 0; } @@ -77,113 +106,147 @@ int mountOpfs(void) { static int mkdirP(const char* path) { char buf[512]; size_t len = strlen(path); + if (len >= sizeof(buf)) return -1; + memcpy(buf, path, len + 1); - for (size_t i = 1; len > i; i++) { + + for (size_t i = 1; i < len; i++) { if (buf[i] == '/') { buf[i] = '\0'; if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; buf[i] = '/'; } } + if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; return 0; } -void* loop() { - double lastFrameStartMs = emscripten_get_now(); // for delta_time and frame pacing +static void cleanupRunner(void) { + if (gRunner == nullptr) { + return; + } - gRunner->gameStartTime = nowNanos(); - while (!gRunner->shouldExit) { - double frameStartMs = emscripten_get_now(); - gRunner->deltaTime = (frameStartMs - lastFrameStartMs) * 1000.0; - lastFrameStartMs = frameStartMs; + fprintf(stderr, "Cleaning up runner!\n"); - RunnerKeyboard_beginFrame(gRunner->keyboard); + if (gRunner->audioSystem != nullptr) { + gRunner->audioSystem->vtable->destroy(gRunner->audioSystem); + gRunner->audioSystem = nullptr; + } - // Process inputs - repeat(GML_KEY_COUNT, i) { - if (keyDown[i]) { - RunnerKeyboard_onKeyDown(gRunner->keyboard, i); - keyDown[i] = 0; - } - if (keyUp[i]) { - RunnerKeyboard_onKeyUp(gRunner->keyboard, i); - keyUp[i] = 0; - } - } + gWebAudio = nullptr; - emscripten_webgl_make_context_current(ctx); + if (gRunner->renderer != nullptr) { + gRunner->renderer->vtable->destroy(gRunner->renderer); + } - float audioDt = (float) (gRunner->deltaTime / 1000000.0); - if (0.0f > audioDt) audioDt = 0.0f; - if (audioDt > 0.1f) audioDt = 0.1f; - gRunner->audioSystem->vtable->update(gRunner->audioSystem, audioDt); + DataWin* dataWin = gRunner->dataWin; + VMContext* vm = gRunner->vmContext; - // Run one game step (Begin Step, Keyboard, Alarms, Step, End Step, room transitions) - Runner_step(gRunner); + Runner_free(gRunner); + gRunner = nullptr; + + VM_free(vm); + DataWin_free(dataWin); - int32_t gameW = (int32_t) gRunner->dataWin->gen8.defaultWindowWidth; - int32_t gameH = (int32_t) gRunner->dataWin->gen8.defaultWindowHeight; + postHostMessage("runnerExit", nullptr); +} - Runner_drawPre(gRunner, 640, 480); +static void frameTick(void* /*arg*/) { + if (gRunner == nullptr) { + gFrameLoopActive = false; + return; + } - Runner_beginFrame(gRunner, gameW, gameH, 640, 480, 640, 480); + if (gRunner->shouldExit) { + gFrameLoopActive = false; + cleanupRunner(); + return; + } - Runner_drawViews(gRunner, gameW, gameH, false); - gRunner->renderer->vtable->endFrameInit(gRunner->renderer); - Runner_drawPost(gRunner, 640, 480); - gRunner->renderer->vtable->endFrameEnd(gRunner->renderer); - Runner_drawGUI(gRunner, 640, 480, gameW, gameH); + double frameStartMs = emscripten_get_now(); + gRunner->deltaTime = (frameStartMs - gLastFrameStartMs) * 1000.0; + gLastFrameStartMs = frameStartMs; - // Just like glfwSwapBuffers. - // Only swap when there isn't a room change to match the original runner. - if (gRunner->pendingRoom == -1) { - emscripten_webgl_commit_frame(); + RunnerKeyboard_beginFrame(gRunner->keyboard); + + // Process inputs. + repeat(GML_KEY_COUNT, i) { + if (keyDown[i]) { + RunnerKeyboard_onKeyDown(gRunner->keyboard, i); + keyDown[i] = 0; } - Runner_handlePendingRoomChange(gRunner); - - // Frame pacing: sleep until the next frame is due, based on the room's speed. - // emscripten_get_now() returns milliseconds (performance.now()) and works in workers. - if (gRunner->currentRoom != nullptr && gRunner->currentRoom->speed > 0) { - double targetFrameTimeMs = 1000.0 / (double) gRunner->currentRoom->speed; - double nextFrameTimeMs = lastFrameStartMs + targetFrameTimeMs; - double remainingMs = nextFrameTimeMs - emscripten_get_now(); - // Sleep for most of the remaining time, then spin-wait for precision. - if (remainingMs > 2.0) { - struct timespec ts; - ts.tv_sec = 0; - ts.tv_nsec = (long) ((remainingMs - 1.0) * 1000000.0); - nanosleep(&ts, nullptr); - } - while (emscripten_get_now() < nextFrameTimeMs) { - // Spin-wait for the remaining sub-millisecond - } + + if (keyUp[i]) { + RunnerKeyboard_onKeyUp(gRunner->keyboard, i); + keyUp[i] = 0; } } - // Cleanup - fprintf(stderr, "Cleaning up runner!\n"); + emscripten_webgl_make_context_current(ctx); - gRunner->audioSystem->vtable->destroy(gRunner->audioSystem); - gRunner->audioSystem = nullptr; - gWebAudio = nullptr; - gRunner->renderer->vtable->destroy(gRunner->renderer); + float audioDt = (float)(gRunner->deltaTime / 1000000.0); + if (audioDt < 0.0f) audioDt = 0.0f; + if (audioDt > 0.1f) audioDt = 0.1f; - DataWin* dataWin = gRunner->dataWin; - VMContext* vm = gRunner->vmContext; - Runner_free(gRunner); - VM_free(vm); - DataWin_free(dataWin); + if (gRunner->audioSystem != nullptr) { + gRunner->audioSystem->vtable->update(gRunner->audioSystem, audioDt); + } + + // Run one game step (Begin Step, Keyboard, Alarms, Step, End Step, room transitions) + Runner_step(gRunner); + + int32_t gameW = (int32_t)gRunner->dataWin->gen8.defaultWindowWidth; + int32_t gameH = (int32_t)gRunner->dataWin->gen8.defaultWindowHeight; + + Runner_drawPre(gRunner, 640, 480); + Runner_beginFrame(gRunner, gameW, gameH, 640, 480, 640, 480); + Runner_drawViews(gRunner, gameW, gameH, false); + gRunner->renderer->vtable->endFrameInit(gRunner->renderer); + Runner_drawPost(gRunner, 640, 480); + gRunner->renderer->vtable->endFrameEnd(gRunner->renderer); + Runner_drawGUI(gRunner, 640, 480, gameW, gameH); - // We want to *know* when the runner has actually exited, because we also need to track things like "Leave Game" buttons/actions in the game - MAIN_THREAD_EM_ASM({ postMessage({ type: 'runnerExit' }); }); + // Swap only when there isn't a room change to match the original runner. + if (gRunner->pendingRoom == -1) { + emscripten_webgl_commit_frame(); + } + Runner_handlePendingRoomChange(gRunner); + + if (gRunner->shouldExit) { + gFrameLoopActive = false; + cleanupRunner(); + return; + } + + // Schedule the next tick without blocking the UI thread. + unsigned int nextDelayMs = 16; + if (gRunner->currentRoom != nullptr && gRunner->currentRoom->speed > 0) { + double targetFrameTimeMs = 1000.0 / (double)gRunner->currentRoom->speed; + double nextFrameTimeMs = gLastFrameStartMs + targetFrameTimeMs; + double remainingMs = nextFrameTimeMs - emscripten_get_now(); + + if (remainingMs < 1.0) { + nextDelayMs = 1; + } else if (remainingMs > 1000.0) { + nextDelayMs = 1000; + } else { + nextDelayMs = (unsigned int)remainingMs; + } + } - return nullptr; + emscripten_async_call(frameTick, nullptr, nextDelayMs); } -void setWindowTitle(const char* title) { - MAIN_THREAD_EM_ASM({ postMessage({ type: 'windowTitle', title: UTF8ToString($0) }); }, title); +static void startFrameLoop(void) { + if (gFrameLoopActive) { + return; + } + + gFrameLoopActive = true; + gLastFrameStartMs = emscripten_get_now(); + emscripten_async_call(frameTick, nullptr, 0); } // gamePath: WASMFS path to the data.win to load (example: "/butterscotch/games/undertale/data.win"). @@ -191,23 +254,28 @@ void setWindowTitle(const char* title) { void startRunner(const char* gamePath, const char* savesPath) { fprintf(stderr, "Starting runner! gamePath=%s savesPath=%s\n", gamePath, savesPath); + if (gRunner != nullptr) { + fprintf(stderr, "A runner is already active. Cleaning up the previous instance first.\n"); + gRunner->shouldExit = true; + cleanupRunner(); + } + EmscriptenWebGLContextAttributes attrs; emscripten_webgl_init_context_attributes(&attrs); attrs.majorVersion = 2; attrs.minorVersion = 0; attrs.alpha = 0; - attrs.antialias = 0; // Required to avoid "WebGL warning: blitFramebuffer: DRAW_FRAMEBUFFER may not have multiple samples." - // Both of these are required to allow us to use emscripten_webgl_commit_frame + attrs.antialias = 0; + + // These are kept for normal single-thread rendering with explicit frame swapping. attrs.explicitSwapControl = true; attrs.renderViaOffscreenBackBuffer = true; - // Yes, "#canvas" feels nasty as HELL - // But that's how Emscripten works for SOME REASON ctx = emscripten_webgl_create_context("#canvas", &attrs); - if (0 >= ctx) { - printf("Failed to create WebGL context: %d\n", (int)ctx); - abort(); + if (ctx <= 0) { + fprintf(stderr, "Failed to create WebGL context: %d\n", (int)ctx); + return; } emscripten_webgl_make_context_current(ctx); @@ -249,52 +317,96 @@ void startRunner(const char* gamePath, const char* savesPath) { options.skipLoadingPreciseMasksForNonPreciseSprites = true; options.lazyLoadRooms = false; options.eagerlyLoadedRooms = nullptr; - DataWin* dataWin = DataWin_parse(gamePath, options); - // return strdup(dataWin->gen8.name); + DataWin* dataWin = DataWin_parse(gamePath, options); + if (dataWin == nullptr) { + fprintf(stderr, "Failed to parse DataWin: %s\n", gamePath); + return; + } - // Initialize VM VMContext* vm = VM_create(dataWin); + if (vm == nullptr) { + fprintf(stderr, "Failed to create VMContext\n"); + DataWin_free(dataWin); + return; + } Renderer* renderer = GLRenderer_create(); + if (renderer == nullptr) { + fprintf(stderr, "Failed to create GLRenderer\n"); + VM_free(vm); + DataWin_free(dataWin); + return; + } // Bundle path = directory containing data.win, e.g. "/butterscotch/games/undertale/". - // Save path = whatever the worker passed in, e.g. "/butterscotch/saves/undertale/". + // Save path = whatever was passed in, e.g. "/butterscotch/saves/undertale/". char* bundleDir = nullptr; const char* lastSlash = strrchr(gamePath, '/'); if (lastSlash != nullptr) { - size_t len = (size_t) (lastSlash - gamePath + 1); - bundleDir = (char *)safeMalloc(len + 1); + size_t len = (size_t)(lastSlash - gamePath + 1); + bundleDir = (char*)safeMalloc(len + 1); memcpy(bundleDir, gamePath, len); bundleDir[len] = '\0'; } else { bundleDir = safeStrdup("./"); } + OverlayFileSystem* overlayFs = OverlayFileSystem_create(bundleDir, savesPath); free(bundleDir); + if (overlayFs == nullptr) { + fprintf(stderr, "Failed to create overlay filesystem\n"); + Renderer_free(renderer); + VM_free(vm); + DataWin_free(dataWin); + return; + } + gWebAudio = WebAudioSystem_create(dataWin, gAudioSampleRate); - AudioSystem* audioSystem = (AudioSystem*) gWebAudio; + if (gWebAudio == nullptr) { + fprintf(stderr, "Failed to create WebAudioSystem\n"); + OverlayFileSystem_free(overlayFs); + Renderer_free(renderer); + VM_free(vm); + DataWin_free(dataWin); + return; + } + + AudioSystem* audioSystem = (AudioSystem*)gWebAudio; + + Runner* runner = Runner_create(dataWin, vm, renderer, (FileSystem*)overlayFs, audioSystem); + if (runner == nullptr) { + fprintf(stderr, "Failed to create runner\n"); + gWebAudio = nullptr; + OverlayFileSystem_free(overlayFs); + Renderer_free(renderer); + VM_free(vm); + DataWin_free(dataWin); + return; + } - // Initialize the runner - Runner* runner = Runner_create(dataWin, vm, renderer, (FileSystem*) overlayFs, audioSystem); runner->setWindowTitle = setWindowTitle; runner->windowHasFocus = nullptr; - setWindowTitle(dataWin->gen8.name); - gRunner = runner; - // Initialize the first room and fire Game Start / Room Start events + setWindowTitle(dataWin->gen8.name); + + // Initialize the first room and fire Game Start / Room Start events. Runner_initFirstRoom(runner); - // Start a new thread - pthread_t tid; - pthread_create(&tid, NULL, loop, NULL); - pthread_detach(tid); + // Start the single-thread frame loop. + startFrameLoop(); } -void stopRunner() { +void stopRunner(void) { fprintf(stderr, "Marked runner to exit!\n"); - gRunner->shouldExit = true; + if (gRunner != nullptr) { + gRunner->shouldExit = true; + } +} + +void setWindowTitle(const char* title) { + postHostMessage("windowTitle", title); } From 0dd43cbe8bb7d41d465807c85b1bc79903f8200c Mon Sep 17 00:00:00 2001 From: "Miguel C. Dias" Date: Thu, 16 Jul 2026 12:06:12 -0300 Subject: [PATCH 4/5] Refactor pointer checks and frame loop handling Updated null pointer checks and initialization for various pointers. Changed boolean flags to integers for frame loop control. Improved error handling and code clarity. --- src/web/main.c | 182 +++++++++++++++++++++++++++---------------------- 1 file changed, 99 insertions(+), 83 deletions(-) diff --git a/src/web/main.c b/src/web/main.c index f3a03ac3d..8960d4135 100644 --- a/src/web/main.c +++ b/src/web/main.c @@ -2,6 +2,7 @@ #include #include #include + #include #include #include @@ -16,27 +17,35 @@ #include "gettime.h" static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE ctx = 0; -static Runner* gRunner = nullptr; -static WebAudioSystem* gWebAudio = nullptr; +static Runner* gRunner = NULL; +static WebAudioSystem* gWebAudio = NULL; static int32_t gAudioSampleRate = 48000; static double gLastFrameStartMs = 0.0; -static bool gFrameLoopActive = false; +static int gFrameLoopActive = 0; uint8_t keyDown[GML_KEY_COUNT] = {0}; uint8_t keyUp[GML_KEY_COUNT] = {0}; +static void frameTick(void* arg); +static void cleanupRunner(void); +static void startFrameLoop(void); +static void setWindowTitle(const char* title); +static void postHostMessage(const char* type, const char* title); + // Configures the sample rate that miniaudio will mix at. Must match the AudioContext's sampleRate // on the JS side, and must be called BEFORE startRunner. void setAudioSampleRate(int32_t rate) { - if (rate > 0) gAudioSampleRate = rate; + if (rate > 0) { + gAudioSampleRate = rate; + } } // Pulls frameCount interleaved-stereo float32 frames into outPtr (which must point into wasm memory). // Called from JS by the audio pull loop. Safe to call before the runner starts (returns silence). void pullAudioFrames(float* outPtr, int32_t frameCount) { - if (gWebAudio == nullptr || frameCount <= 0) { - if (outPtr != nullptr && frameCount > 0) { + if (gWebAudio == NULL || frameCount <= 0) { + if (outPtr != NULL && frameCount > 0) { memset(outPtr, 0, (size_t)frameCount * 2 * sizeof(float)); } return; @@ -57,31 +66,9 @@ int getKeyCount() { return GML_KEY_COUNT; } -static void postHostMessage(const char* type, const char* title) { - EM_ASM({ - const payload = { type: UTF8ToString($0) }; - - if ($1) { - payload.title = UTF8ToString($1); - } - - try { - if (typeof window !== 'undefined' && - window.parent && - window.parent !== window && - typeof window.parent.postMessage === 'function') { - window.parent.postMessage(payload, '*'); - } else if (typeof self !== 'undefined' && typeof self.postMessage === 'function') { - self.postMessage(payload); - } else if (typeof postMessage === 'function') { - try { postMessage(payload); } catch (_) {} - } - } catch (_) {} - }, type, title); -} - int main(void) { - printf("Howdy! Loritta is so cute! lol\n"); + printf("Butterscotch Web single-thread build ready.\n"); + emscripten_exit_with_live_runtime(); return 0; } @@ -107,37 +94,67 @@ static int mkdirP(const char* path) { char buf[512]; size_t len = strlen(path); - if (len >= sizeof(buf)) return -1; + if (len >= sizeof(buf)) { + return -1; + } memcpy(buf, path, len + 1); for (size_t i = 1; i < len; i++) { if (buf[i] == '/') { buf[i] = '\0'; - if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; + if (mkdir(buf, 0777) != 0 && errno != EEXIST) { + return -1; + } buf[i] = '/'; } } - if (mkdir(buf, 0777) != 0 && errno != EEXIST) return -1; + if (mkdir(buf, 0777) != 0 && errno != EEXIST) { + return -1; + } + return 0; } +static void postHostMessage(const char* type, const char* title) { + EM_ASM({ + const payload = { type: UTF8ToString($0) }; + + if ($1) { + payload.title = UTF8ToString($1); + } + + try { + if (typeof window !== 'undefined' && + window.parent && + window.parent !== window && + typeof window.parent.postMessage === 'function') { + window.parent.postMessage(payload, '*'); + } else if (typeof self !== 'undefined' && typeof self.postMessage === 'function') { + self.postMessage(payload); + } else if (typeof postMessage === 'function') { + try { postMessage(payload); } catch (_) {} + } + } catch (_) {} + }, type, title); +} + static void cleanupRunner(void) { - if (gRunner == nullptr) { + if (gRunner == NULL) { return; } fprintf(stderr, "Cleaning up runner!\n"); - if (gRunner->audioSystem != nullptr) { + if (gRunner->audioSystem != NULL) { gRunner->audioSystem->vtable->destroy(gRunner->audioSystem); - gRunner->audioSystem = nullptr; + gRunner->audioSystem = NULL; } - gWebAudio = nullptr; + gWebAudio = NULL; - if (gRunner->renderer != nullptr) { + if (gRunner->renderer != NULL) { gRunner->renderer->vtable->destroy(gRunner->renderer); } @@ -145,22 +162,24 @@ static void cleanupRunner(void) { VMContext* vm = gRunner->vmContext; Runner_free(gRunner); - gRunner = nullptr; + gRunner = NULL; VM_free(vm); DataWin_free(dataWin); - postHostMessage("runnerExit", nullptr); + postHostMessage("runnerExit", NULL); } -static void frameTick(void* /*arg*/) { - if (gRunner == nullptr) { - gFrameLoopActive = false; +static void frameTick(void* arg) { + (void)arg; + + if (gRunner == NULL) { + gFrameLoopActive = 0; return; } if (gRunner->shouldExit) { - gFrameLoopActive = false; + gFrameLoopActive = 0; cleanupRunner(); return; } @@ -171,7 +190,7 @@ static void frameTick(void* /*arg*/) { RunnerKeyboard_beginFrame(gRunner->keyboard); - // Process inputs. + // Process inputs repeat(GML_KEY_COUNT, i) { if (keyDown[i]) { RunnerKeyboard_onKeyDown(gRunner->keyboard, i); @@ -187,10 +206,14 @@ static void frameTick(void* /*arg*/) { emscripten_webgl_make_context_current(ctx); float audioDt = (float)(gRunner->deltaTime / 1000000.0); - if (audioDt < 0.0f) audioDt = 0.0f; - if (audioDt > 0.1f) audioDt = 0.1f; + if (audioDt < 0.0f) { + audioDt = 0.0f; + } + if (audioDt > 0.1f) { + audioDt = 0.1f; + } - if (gRunner->audioSystem != nullptr) { + if (gRunner->audioSystem != NULL) { gRunner->audioSystem->vtable->update(gRunner->audioSystem, audioDt); } @@ -208,21 +231,19 @@ static void frameTick(void* /*arg*/) { gRunner->renderer->vtable->endFrameEnd(gRunner->renderer); Runner_drawGUI(gRunner, 640, 480, gameW, gameH); - // Swap only when there isn't a room change to match the original runner. - if (gRunner->pendingRoom == -1) { - emscripten_webgl_commit_frame(); - } + // In single-thread WebGL mode, let the browser present the frame normally. + glFlush(); + Runner_handlePendingRoomChange(gRunner); if (gRunner->shouldExit) { - gFrameLoopActive = false; + gFrameLoopActive = 0; cleanupRunner(); return; } - // Schedule the next tick without blocking the UI thread. unsigned int nextDelayMs = 16; - if (gRunner->currentRoom != nullptr && gRunner->currentRoom->speed > 0) { + if (gRunner->currentRoom != NULL && gRunner->currentRoom->speed > 0) { double targetFrameTimeMs = 1000.0 / (double)gRunner->currentRoom->speed; double nextFrameTimeMs = gLastFrameStartMs + targetFrameTimeMs; double remainingMs = nextFrameTimeMs - emscripten_get_now(); @@ -236,7 +257,7 @@ static void frameTick(void* /*arg*/) { } } - emscripten_async_call(frameTick, nullptr, nextDelayMs); + emscripten_async_call(frameTick, NULL, nextDelayMs); } static void startFrameLoop(void) { @@ -244,9 +265,9 @@ static void startFrameLoop(void) { return; } - gFrameLoopActive = true; + gFrameLoopActive = 1; gLastFrameStartMs = emscripten_get_now(); - emscripten_async_call(frameTick, nullptr, 0); + emscripten_async_call(frameTick, NULL, 0); } // gamePath: WASMFS path to the data.win to load (example: "/butterscotch/games/undertale/data.win"). @@ -254,7 +275,7 @@ static void startFrameLoop(void) { void startRunner(const char* gamePath, const char* savesPath) { fprintf(stderr, "Starting runner! gamePath=%s savesPath=%s\n", gamePath, savesPath); - if (gRunner != nullptr) { + if (gRunner != NULL) { fprintf(stderr, "A runner is already active. Cleaning up the previous instance first.\n"); gRunner->shouldExit = true; cleanupRunner(); @@ -268,13 +289,10 @@ void startRunner(const char* gamePath, const char* savesPath) { attrs.alpha = 0; attrs.antialias = 0; - // These are kept for normal single-thread rendering with explicit frame swapping. - attrs.explicitSwapControl = true; - attrs.renderViaOffscreenBackBuffer = true; - + // Keep this build simple: no explicit swap control, no offscreen backbuffer, no worker dependence. ctx = emscripten_webgl_create_context("#canvas", &attrs); if (ctx <= 0) { - fprintf(stderr, "Failed to create WebGL context: %d\n", (int)ctx); + printf("Failed to create WebGL context: %d\n", (int)ctx); return; } @@ -284,7 +302,7 @@ void startRunner(const char* gamePath, const char* savesPath) { glClear(GL_COLOR_BUFFER_BIT); // Make sure the saves directory exists. The FileSystem impl will write into it. - if (savesPath != nullptr && savesPath[0] != '\0') { + if (savesPath != NULL && savesPath[0] != '\0') { if (mkdirP(savesPath) != 0) { fprintf(stderr, "Warning: failed to ensure saves dir exists at %s: %s\n", savesPath, strerror(errno)); } @@ -316,23 +334,23 @@ void startRunner(const char* gamePath, const char* savesPath) { options.parseAudo = true; options.skipLoadingPreciseMasksForNonPreciseSprites = true; options.lazyLoadRooms = false; - options.eagerlyLoadedRooms = nullptr; + options.eagerlyLoadedRooms = NULL; DataWin* dataWin = DataWin_parse(gamePath, options); - if (dataWin == nullptr) { + if (dataWin == NULL) { fprintf(stderr, "Failed to parse DataWin: %s\n", gamePath); return; } VMContext* vm = VM_create(dataWin); - if (vm == nullptr) { + if (vm == NULL) { fprintf(stderr, "Failed to create VMContext\n"); DataWin_free(dataWin); return; } Renderer* renderer = GLRenderer_create(); - if (renderer == nullptr) { + if (renderer == NULL) { fprintf(stderr, "Failed to create GLRenderer\n"); VM_free(vm); DataWin_free(dataWin); @@ -341,9 +359,9 @@ void startRunner(const char* gamePath, const char* savesPath) { // Bundle path = directory containing data.win, e.g. "/butterscotch/games/undertale/". // Save path = whatever was passed in, e.g. "/butterscotch/saves/undertale/". - char* bundleDir = nullptr; + char* bundleDir = NULL; const char* lastSlash = strrchr(gamePath, '/'); - if (lastSlash != nullptr) { + if (lastSlash != NULL) { size_t len = (size_t)(lastSlash - gamePath + 1); bundleDir = (char*)safeMalloc(len + 1); memcpy(bundleDir, gamePath, len); @@ -355,19 +373,18 @@ void startRunner(const char* gamePath, const char* savesPath) { OverlayFileSystem* overlayFs = OverlayFileSystem_create(bundleDir, savesPath); free(bundleDir); - if (overlayFs == nullptr) { + if (overlayFs == NULL) { fprintf(stderr, "Failed to create overlay filesystem\n"); - Renderer_free(renderer); + renderer->vtable->destroy(renderer); VM_free(vm); DataWin_free(dataWin); return; } gWebAudio = WebAudioSystem_create(dataWin, gAudioSampleRate); - if (gWebAudio == nullptr) { + if (gWebAudio == NULL) { fprintf(stderr, "Failed to create WebAudioSystem\n"); - OverlayFileSystem_free(overlayFs); - Renderer_free(renderer); + renderer->vtable->destroy(renderer); VM_free(vm); DataWin_free(dataWin); return; @@ -376,18 +393,17 @@ void startRunner(const char* gamePath, const char* savesPath) { AudioSystem* audioSystem = (AudioSystem*)gWebAudio; Runner* runner = Runner_create(dataWin, vm, renderer, (FileSystem*)overlayFs, audioSystem); - if (runner == nullptr) { + if (runner == NULL) { fprintf(stderr, "Failed to create runner\n"); - gWebAudio = nullptr; - OverlayFileSystem_free(overlayFs); - Renderer_free(renderer); + gWebAudio = NULL; + renderer->vtable->destroy(renderer); VM_free(vm); DataWin_free(dataWin); return; } runner->setWindowTitle = setWindowTitle; - runner->windowHasFocus = nullptr; + runner->windowHasFocus = NULL; gRunner = runner; @@ -402,11 +418,11 @@ void startRunner(const char* gamePath, const char* savesPath) { void stopRunner(void) { fprintf(stderr, "Marked runner to exit!\n"); - if (gRunner != nullptr) { + if (gRunner != NULL) { gRunner->shouldExit = true; } } -void setWindowTitle(const char* title) { +static void setWindowTitle(const char* title) { postHostMessage("windowTitle", title); } From d2606411e5b5f801554f09d4322b12687095fda3 Mon Sep 17 00:00:00 2001 From: "Miguel C. Dias" Date: Thu, 16 Jul 2026 12:11:24 -0300 Subject: [PATCH 5/5] Change suffix to .js and update link options for web Updated build configuration for web platform to use classic JS instead of ES module and modified target link options. --- CMakeLists.txt | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 924d470fa..5c72e4141 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -321,10 +321,13 @@ if(PLATFORM STREQUAL "desktop") ${CMAKE_SOURCE_DIR}/vendor/gamecontrollerdb.txt $/gamecontrollerdb.txt ) +elseif(PLATFORM STREQUAL "web") + if (NOT ENABLE_MODERN_GL) elseif(PLATFORM STREQUAL "web") if (NOT ENABLE_MODERN_GL) message(FATAL_ERROR "Web requires modern gl!") endif() + file(GLOB GL_SOURCES src/gl/*.c src/gl_common/*.c src/image/*.c) target_sources(butterscotch PRIVATE ${GL_SOURCES}) target_include_directories(butterscotch PRIVATE ${CMAKE_SOURCE_DIR}/src/gl) @@ -337,29 +340,24 @@ elseif(PLATFORM STREQUAL "web") # bzip2 target_link_libraries(butterscotch PRIVATE bzip2 stb_ds sha1 stb_vorbis) - set_target_properties(butterscotch PROPERTIES SUFFIX ".mjs") + # Build as classic JS instead of ES module + set_target_properties(butterscotch PROPERTIES SUFFIX ".js") target_link_options( - butterscotch - PRIVATE - "-sMODULARIZE=1" - "-sEXPORT_ES6=1" - "-sENVIRONMENT=worker" - "-sWASMFS=1" - "-sEXPORTED_RUNTIME_METHODS=['HEAP8', 'HEAPU8', 'HEAPF32', 'ccall', 'cwrap', 'specialHTMLTargets', 'UTF8ToString']" - "-sEXPORTED_FUNCTIONS=['_main', '_mountOpfs', '_startRunner', '_stopRunner', '_malloc', '_free', '_getKeyDownPtr', '_getKeyUpPtr', '_getKeyCount', '_setAudioSampleRate', '_pullAudioFrames']" - "-sOFFSCREENCANVAS_SUPPORT=1" - "-sOFFSCREEN_FRAMEBUFFER=1" - "-sMIN_WEBGL_VERSION=2" - "-sMAX_WEBGL_VERSION=2" - "-sFULL_ES3=1" - "-sALLOW_MEMORY_GROWTH" - "-sSHARED_MEMORY=1" - "-sPTHREAD_POOL_SIZE=4" - "-pthread" + butterscotch + PRIVATE + "-sMODULARIZE=0" + "-sEXPORT_ES6=0" + "-sENVIRONMENT=web" + "-sWASMFS=1" + "-sEXPORTED_RUNTIME_METHODS=['FS','HEAP8','HEAPU8','HEAPF32','ccall','cwrap','UTF8ToString']" + "-sEXPORTED_FUNCTIONS=['_main','_mountOpfs','_startRunner','_stopRunner','_malloc','_free','_getKeyDownPtr','_getKeyUpPtr','_getKeyCount','_setAudioSampleRate','_pullAudioFrames']" + "-sMIN_WEBGL_VERSION=2" + "-sMAX_WEBGL_VERSION=2" + "-sFULL_ES3=1" + "-sALLOW_MEMORY_GROWTH=1" ) - - target_compile_options(butterscotch PRIVATE "-pthread") + elseif(PLATFORM STREQUAL "web-meta") set_target_properties(butterscotch PROPERTIES SUFFIX ".mjs"