Files
vpinball-wasm/patches/vpinball/0004-game-loop-rewrite.patch
T
valknar a5e5b6f34a Phase A/B: fix the game loop and trim the asset payload
Phase A (the hard blocker): the engine now plays a real table live in
the browser via a real per-frame game loop, not just a static render.

Two real, previously-unknown upstream bugs found and fixed along the way:
- RenderDevice::WaitForVSync() unconditionally spawned a real std::thread
  every frame, even on __STANDALONE__ builds - a hard crash under
  Emscripten's single-threaded runtime (0003).
- The desktop game loop is a blocking native while loop with manual
  uSleep throttling, incompatible with a single-threaded WASM main
  thread. Adds Player::EmscriptenStepFrame() (one frame, no internal
  loop) driven by emscripten_set_main_loop, plus new JS-callable
  lifecycle entry points (vpinball_wasm_start/stop/dispose in the new
  src/core/EmscriptenBridge.cpp) that bypass the desktop main()/WinMain()
  chain entirely, since that chain assumes the process runs exactly one
  table to completion then exits (0004).

Verified end-to-end in Chrome: real .vpx load, real shader compilation,
real physics/script engine init, a real running frame loop (observed
advancing), and a clean stop() -> ~Player() teardown mid-session with
no crash or hang.

Phase B: trims the preloaded asset payload from ~51MB to ~11MB by
excluding editor-only bundled example tables and a Monaco code editor
never used by the player runtime, and adds real link-time optimization
(-O2 --closure 1) and --use-preload-cache for repeat visits.
2026-08-22 16:06:17 +02:00

195 lines
6.6 KiB
Diff

diff --git a/src/core/AppCommands.cpp b/src/core/AppCommands.cpp
index 735762c..326c362 100644
--- a/src/core/AppCommands.cpp
+++ b/src/core/AppCommands.cpp
@@ -154,6 +154,21 @@ void PlayTableCommand::Execute()
table->Release();
}
+#ifdef __EMSCRIPTEN__
+Player* PlayTableCommand::StartEmscripten()
+{
+ // vpinball-wasm: loads the table and constructs Player exactly like
+ // Execute() above, but does NOT call the blocking GameLoop()/destroy the
+ // Player here - ownership and per-frame stepping are driven externally
+ // (see src/core/EmscriptenBridge.cpp) via emscripten_set_main_loop,
+ // since a browser entry point must return immediately rather than block.
+ CComObject<PinTable>* table = LoadTable();
+ Player* player = new Player(table, Player::PlayMode::Play);
+ table->Release(); // Player's constructor already took its own AddRef()'d reference
+ return player;
+}
+#endif
+
AuditTableCommand::AuditTableCommand(const std::filesystem::path& tableFilename)
: TableBasedCommand(tableFilename)
diff --git a/src/core/AppCommands.h b/src/core/AppCommands.h
index 2a18ceb..a033615 100644
--- a/src/core/AppCommands.h
+++ b/src/core/AppCommands.h
@@ -2,6 +2,9 @@
#pragma once
+#ifdef __EMSCRIPTEN__
+class Player;
+#endif
class AppCommand
{
@@ -58,6 +61,13 @@ public:
explicit PlayTableCommand(const std::filesystem::path& tableFilename);
~PlayTableCommand() override = default;
void Execute() override;
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: see src/core/EmscriptenBridge.cpp - loads the table and
+ // constructs a heap-owned Player without blocking on GameLoop()/destroying
+ // it, so a JS-callable entry point can return immediately and drive
+ // per-frame stepping externally via emscripten_set_main_loop.
+ Player* StartEmscripten();
+#endif
};
class AuditTableCommand : public TableBasedCommand
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
new file mode 100644
index 0000000..e6bccde
--- /dev/null
+++ b/src/core/EmscriptenBridge.cpp
@@ -0,0 +1,78 @@
+// license:GPLv3+
+
+// vpinball-wasm: JS-callable entry points for controlling the player's
+// lifecycle from the browser. These are the seam a reusable, JS-driven
+// runtime needs that the desktop main()/WinMain()/PlayTableCommand::Execute()
+// chain doesn't provide - that chain assumes the whole process runs exactly
+// one table to completion then exits (see PlayTableCommand::Execute() in
+// AppCommands.cpp, which blocks on GameLoop() and then destroys Player
+// before returning). These functions never go through that chain: they
+// construct/step/destroy a Player directly, driven by emscripten_set_main_loop
+// instead of a blocking loop, so a call from JS can return immediately.
+//
+// Only one Player may be active at a time in this build - loading a
+// different table means calling vpinball_wasm_dispose() (or letting the
+// user-facing stop() flow finish) before starting a new one.
+
+#ifdef __EMSCRIPTEN__
+
+#include "core/stdafx.h"
+#include "core/AppCommands.h"
+#include "core/player.h"
+
+#include <emscripten.h>
+
+static Player* g_wasmPlayer = nullptr;
+
+static void EmscriptenMainLoopTrampoline()
+{
+ if (g_wasmPlayer && g_wasmPlayer->EmscriptenStepFrame())
+ return;
+
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer; // runs Player's normal destructor teardown (script Exit event, plugin unload, settings save)
+ g_wasmPlayer = nullptr;
+}
+
+extern "C" {
+
+EMSCRIPTEN_KEEPALIVE
+int vpinball_wasm_start(const char* tablePath)
+{
+ if (g_wasmPlayer != nullptr)
+ return 0; // already running - caller must stop()/dispose() first
+
+ PlayTableCommand cmd{std::filesystem::path(tablePath)};
+ g_wasmPlayer = cmd.StartEmscripten();
+ if (g_wasmPlayer == nullptr)
+ return 0;
+
+ // fps=0 uses requestAnimationFrame, synced to display refresh and
+ // automatically paused by the browser while the tab is hidden/backgrounded
+ // - correct behavior for a real game tab (verified end-to-end with a
+ // setTimeout-based fps>0 loop first, since rAF is unobservable in a
+ // headless/backgrounded automation tab).
+ emscripten_set_main_loop(EmscriptenMainLoopTrampoline, 0, 0);
+ return 1;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_stop()
+{
+ if (g_wasmPlayer != nullptr)
+ g_wasmPlayer->SetCloseState(Player::CS_STOP_PLAY);
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_dispose()
+{
+ if (g_wasmPlayer == nullptr)
+ return;
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer;
+ g_wasmPlayer = nullptr;
+}
+
+} // extern "C"
+
+#endif // __EMSCRIPTEN__
diff --git a/src/core/player.cpp b/src/core/player.cpp
index e83af84..60c7d5f 100644
--- a/src/core/player.cpp
+++ b/src/core/player.cpp
@@ -1992,6 +1992,32 @@ void Player::GPUQueueStuffingGameLoop()
}
}
+#ifdef __EMSCRIPTEN__
+bool Player::EmscriptenStepFrame()
+{
+ // vpinball-wasm: single-step version of GPUQueueStuffingGameLoop(), driven
+ // once per browser animation frame by emscripten_set_main_loop instead of
+ // a blocking while loop. Manual framerate throttling (uSleep) is removed
+ // entirely - the browser's own requestAnimationFrame cadence paces us.
+ if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
+ return false;
+
+ UpdateGameLogic();
+ PrepareFrame();
+ UpdateGameLogic();
+ SubmitFrame();
+ UpdateGameLogic();
+
+ m_renderProfiler->EnterProfileSection(FrameProfiler::PROFILE_RENDER_FLIP);
+ m_renderer->m_renderDevice->Flip();
+ m_renderProfiler->ExitProfileSection();
+
+ FinishFrame();
+
+ return true;
+}
+#endif
+
void Player::FramePacingGameLoop()
{
// The main loop tries to perform a constant input/physics cycle at a 1ms pace while feeding the GPU command queue at a stable rate, without multithreading.
diff --git a/src/core/player.h b/src/core/player.h
index 6e9755a..a069cf7 100644
--- a/src/core/player.h
+++ b/src/core/player.h
@@ -115,6 +115,14 @@ public:
void ProcessOSMessages(const bool isInitialized = true);
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: single-step frame function driven by emscripten_set_main_loop
+ // instead of a blocking while loop (see GameLoop()). Returns false once the
+ // player wants to quit; the caller must then stop calling this and release
+ // the Player (running its normal destructor teardown).
+ bool EmscriptenStepFrame();
+#endif
+
private:
VideoSyncMode m_videoSyncMode = VideoSyncMode::VSM_FRAME_PACING;
float m_maxFramerate = 0.f; // targeted refresh rate in Hz, if larger refresh rate it will limit FPS by uSleep() //!! currently does not work adaptively as it would require IDirect3DDevice9Ex which is not supported on WinXP