From 2cfaa37344a6b48c36754ba556f6c7455d98d5f0 Mon Sep 17 00:00:00 2001 From: Resurrected Trader Date: Thu, 8 Oct 2026 09:14:06 +0100 Subject: [PATCH 1/3] runtime: add a per-frame "render" script event as a frame-counter signal addEventListener("render", (frame) => ...) runs at most once per rendered frame, in and out of game, so scripts can move or restyle drawables every frame. GameLoop::OnDraw only increments a global frame counter (events::RenderFrames) before Drawable::DrawAll; it queues nothing and never waits on a script. Each pass of a script's event loop (Script::ExecuteEvents, inside delay()) fires its render listeners once when the counter moved since it last did, so a slow script sees a bigger jump in frame rather than a backlog, and no script listening costs nothing beyond the increment. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/game_thread_safety.md | 16 +++++++ src/contract/game/GameCallbacks.h | 5 +- .../components/events/EventDispatch.cpp | 5 ++ src/runtime/components/events/EventDispatch.h | 3 ++ src/runtime/components/events/Events.h | 23 +++++++++ src/runtime/components/events/RenderFrames.h | 33 +++++++++++++ src/runtime/components/gameloop/GameLoop.cpp | 1 + src/runtime/components/gameloop/GameLoop.h | 4 +- src/runtime/components/script/Script.cpp | 14 +++++- src/runtime/components/script/Script.h | 6 +++ src/runtime/runtime.vcxproj | 1 + tests/runtime/events/RenderFramesTest.cpp | 48 +++++++++++++++++++ tests/runtime/fakes/GameLoopCollaborators.cpp | 5 ++ tests/runtime/fakes/GameLoopCollaborators.h | 3 ++ .../shims/components/events/EventDispatch.h | 3 +- tests/runtime/gameloop/GameLoopTest.cpp | 17 +++++++ tests/runtime/js_tests.vcxproj | 2 + 17 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/runtime/components/events/RenderFrames.h create mode 100644 tests/runtime/events/RenderFramesTest.cpp diff --git a/docs/game_thread_safety.md b/docs/game_thread_safety.md index c1d3871..4e38552 100644 --- a/docs/game_thread_safety.md +++ b/docs/game_thread_safety.md @@ -180,6 +180,22 @@ game::GameThread::Execute([&] { `Execute()` releases the script's `GameReadLock` so the game thread can acquire `GameWriteLock` to drain the task queue. +## Per-frame events (`render`) + +The `render` event is a signal, not a queued event. `GameLoop::OnDraw` (the backend's `onDraw` callback, once +per rendered frame, in and out of game) only increments a global frame counter (`events::RenderFrames`, +`components/events/RenderFrames.h`) before `Drawable::DrawAll`; it queues nothing and never waits on a script, +so a frame costs one atomic increment whether or not any script listens. Each pass of a script's event loop +(`Script::ExecuteEvents`, i.e. inside `delay()`) compares the counter with the frame the script last fired +`render` for and, if it moved, fires the `render` listeners once with the latest frame number. No wake-up is +needed: the loop already polls every `IdleSleepIntervalMs`, the same latency as any cross-thread posted event. A +slow script just sees a bigger jump in `frame`; there is no backlog to drain. + +Handlers run on the script's thread, so a frame is usually already being drawn by then: a drawable change made +in a `render` handler typically shows from the next frame. Drawable fields are atomics, so the handler needs no +game lock to change them; reading game state from it takes the usual `GameReadLock`, which waits for the game +thread's sleep window. + ## Lock Releasers ### GameWriteLockReleaser diff --git a/src/contract/game/GameCallbacks.h b/src/contract/game/GameCallbacks.h index 306ac52..e7973cb 100644 --- a/src/contract/game/GameCallbacks.h +++ b/src/contract/game/GameCallbacks.h @@ -105,8 +105,9 @@ struct GameCallbacks { // the game thread (pass through to real Sleep). void (*onSleep)(std::chrono::milliseconds duration) = nullptr; - // Called from the game's render function. - // Framework flushes drawables so overlays composite with the current frame. + // Called from the game's render function, once per rendered frame (in and out of game). + // Framework counts the frame for the script "render" event, then flushes drawables so + // overlays composite with the current frame. void (*onDraw)() = nullptr; }; diff --git a/src/runtime/components/events/EventDispatch.cpp b/src/runtime/components/events/EventDispatch.cpp index 9d7b1a1..2862811 100644 --- a/src/runtime/components/events/EventDispatch.cpp +++ b/src/runtime/components/events/EventDispatch.cpp @@ -8,6 +8,7 @@ #include #include "Events.h" +#include "RenderFrames.h" #include "components/script/Commands.h" #include "components/script/ScriptEngine.h" #include "config/AppConfig.h" @@ -128,6 +129,10 @@ void CopyDataEventDispatch(game::IpcMode mode, const std::string& payload) { FireIfListening(mode, payload); } +void RenderEventDispatch() { + RenderFrames::Count(); +} + void ScriptBroadcastEventDispatch(const v8::FunctionCallbackInfo& args) { // Not probed: constructing this serialises the arguments, which can run script code (getters), // so skipping it when nobody listens would be observable. diff --git a/src/runtime/components/events/EventDispatch.h b/src/runtime/components/events/EventDispatch.h index 5fbcbea..5e29667 100644 --- a/src/runtime/components/events/EventDispatch.h +++ b/src/runtime/components/events/EventDispatch.h @@ -36,6 +36,9 @@ void ItemActionEventDispatch(uint32_t unitId, uint32_t action, const std::string void GameActionEventDispatch(int32_t mode, uint32_t param1, uint32_t param2, const std::string& name1, const std::string& name2); void CopyDataEventDispatch(game::IpcMode mode, const std::string& payload); +// Once per rendered frame, on the game thread, before the drawables are drawn. Only counts the frame: +// each script fires its "render" listeners from its own event loop (Script::ExecuteEvents). +void RenderEventDispatch(); void ScriptBroadcastEventDispatch(const v8::FunctionCallbackInfo& args); // Blockable event dispatchers (return true if event was blocked) diff --git a/src/runtime/components/events/Events.h b/src/runtime/components/events/Events.h index 0edc755..d34d24f 100644 --- a/src/runtime/components/events/Events.h +++ b/src/runtime/components/events/Events.h @@ -520,6 +520,29 @@ class BroadcastEvent : public BaseEvent { std::vector> values_; }; +// ============================================================================ +// Render Event +// ============================================================================ + +/// @event A frame was rendered (in game and out of game). Fires at most once per frame, so handlers can move or +/// restyle drawables every frame. Handlers run on the script's own thread whenever its event loop runs (inside +/// delay()), so a change usually shows from the next frame. A script that falls behind fires once for all the frames +/// it missed rather than once per frame. +/// @param frame {number} - the number of the most recently rendered frame, counted from load; compare with the previous +/// call's to see how many frames were skipped +class RenderEvent : public BaseEvent { + protected: + std::vector> MakeArgs(v8::Isolate* isolate) const override { + return {api::convert::ToJS(isolate, frame)}; + } + + public: + explicit RenderEvent(uint32_t frame) : frame(frame) {} + const uint32_t frame; + static constexpr std::string_view EVENT_NAME = "render"; + [[nodiscard]] std::string_view Name() const override { return EVENT_NAME; } +}; + // ============================================================================ // Screen Hook Events // ============================================================================ diff --git a/src/runtime/components/events/RenderFrames.h b/src/runtime/components/events/RenderFrames.h new file mode 100644 index 0000000..e4cf0ea --- /dev/null +++ b/src/runtime/components/events/RenderFrames.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace d2bs::runtime::events { + +// The per-frame "render" event is a signal, not a queued event: the game thread only counts rendered +// frames, and each script's event loop fires its render listeners when the count has moved since it +// last did. Nothing is queued per frame, so a script that falls behind just sees a bigger jump. Kept +// free of V8 so the coalescing can be tested on its own. +class RenderFrames { + inline static std::atomic count_ = 0; + + public: + // Game thread, once per rendered frame. + static void Count() { count_.fetch_add(1, std::memory_order_relaxed); } + + // Any thread. The number of the most recently rendered frame (the first is 1). + static uint32_t Latest() { return count_.load(std::memory_order_relaxed); } + + // True if a frame was rendered since `seen`, which then moves to the latest frame. + static bool TakeNew(uint32_t& seen) { + const uint32_t latest = Latest(); + if (latest == seen) { + return false; + } + seen = latest; + return true; + } +}; + +} // namespace d2bs::runtime::events diff --git a/src/runtime/components/gameloop/GameLoop.cpp b/src/runtime/components/gameloop/GameLoop.cpp index 97920d3..90f4590 100644 --- a/src/runtime/components/gameloop/GameLoop.cpp +++ b/src/runtime/components/gameloop/GameLoop.cpp @@ -209,6 +209,7 @@ void GameLoop::OnSleep(std::chrono::milliseconds duration) { void GameLoop::OnDraw() const { // Fires from the render function, before the next OnSleep has taken a new // snapshot - previous_.state is the most recently observed game state. + events::RenderEventDispatch(); drawing::Drawable::DrawAll(previous_.state); // Always-on "d2bsng " corner banner (carries the update-available // marker). Drawn after script drawables so it stays on top. diff --git a/src/runtime/components/gameloop/GameLoop.h b/src/runtime/components/gameloop/GameLoop.h index ef439ba..5a814cf 100644 --- a/src/runtime/components/gameloop/GameLoop.h +++ b/src/runtime/components/gameloop/GameLoop.h @@ -75,8 +75,8 @@ class GameLoop { // left held on exit so the next frame body starts under the write lock. void OnSleep(std::chrono::milliseconds duration); - // Called from the game's render function. Flushes drawables for the most - // recently observed game state. + // Called from the game's render function. Counts the frame for the script + // "render" event, then flushes drawables for the most recently observed game state. void OnDraw() const; // Attributes the caller's scope to `phase` on the game thread's timeline. Inert on other diff --git a/src/runtime/components/script/Script.cpp b/src/runtime/components/script/Script.cpp index be96430..c0420d5 100644 --- a/src/runtime/components/script/Script.cpp +++ b/src/runtime/components/script/Script.cpp @@ -24,6 +24,7 @@ #include "components/events/DelayedEvent.h" #include "components/events/EventDispatch.h" #include "components/events/Events.h" +#include "components/events/RenderFrames.h" #include "components/inspector/ScriptInspector.h" #include "components/script/CompileSource.h" #include "components/script/NativeCallHook.h" @@ -73,7 +74,8 @@ Script::Script(std::filesystem::path path, ScriptMode mode, std::vector GetLogger(v8::Isolate* isolate) { if (auto* script = ScriptEngine::Instance().GetScript(isolate)) { @@ -887,6 +889,8 @@ void Script::ExecuteEvents(std::chrono::milliseconds duration) { inspector_->DrainIncoming(); } + FireRenderIfNewFrame(); + // steady_clock::now() is QueryPerformanceCounter on MSVC, so each read // routes through the speedhack hook: take one reading per pass and reuse it // for the heap-stat throttle, the deadline check, and the wait. @@ -918,6 +922,14 @@ void Script::ExecuteEvents(std::chrono::milliseconds duration) { } } +void Script::FireRenderIfNewFrame() { + // The frame is consumed even with no listener, so adding one later waits for the next frame. + if (!events::RenderFrames::TakeNew(lastRenderFrame_) || !IsEventRegistered(events::RenderEvent::EVENT_NAME)) { + return; + } + ExecuteEvent(std::make_shared(lastRenderFrame_)); +} + bool Script::ExecuteEvent(const std::shared_ptr& event) { auto* iso = isolate_.load().get(); if (!iso) diff --git a/src/runtime/components/script/Script.h b/src/runtime/components/script/Script.h index ba47c1b..3079123 100644 --- a/src/runtime/components/script/Script.h +++ b/src/runtime/components/script/Script.h @@ -229,6 +229,10 @@ class Script : public std::enable_shared_from_this