diff --git a/docs/game_thread_safety.md b/docs/game_thread_safety.md index c1d3871..192f46a 100644 --- a/docs/game_thread_safety.md +++ b/docs/game_thread_safety.md @@ -180,6 +180,24 @@ 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::FrameCounter`, +`components/events/FrameCounter.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 and the script listens for `render`, fires the 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. The loop never fires `render` inside a `render` +handler: a handler that calls `delay()` would otherwise see a new frame and nest one level deeper every frame. + +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/Events.h b/src/runtime/components/events/Events.h index 0edc755..2bcb840 100644 --- a/src/runtime/components/events/Events.h +++ b/src/runtime/components/events/Events.h @@ -520,6 +520,30 @@ 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; render never fires inside its own handler, so a delay() there skips the frames +/// drawn meanwhile. +/// @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, static_cast(frame))}; + } + + public: + explicit RenderEvent(uint64_t frame) : frame(frame) {} + const uint64_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/FrameCounter.h b/src/runtime/components/events/FrameCounter.h new file mode 100644 index 0000000..453de1b --- /dev/null +++ b/src/runtime/components/events/FrameCounter.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace d2bs::runtime::events { + +// Rendered frames since load. The game thread counts each frame; each script fires its "render" +// listeners from its own event loop when the count has moved past the frame it last fired for, so +// nothing is queued per frame and a script that falls behind just sees a bigger jump. +class FrameCounter { + inline static std::atomic count_ = 0; + + public: + // Game thread, once per rendered frame. + static void Increment() { count_.fetch_add(1, std::memory_order_relaxed); } + + // Any thread. The number of the most recently rendered frame (the first is 1). + static uint64_t Current() { return count_.load(std::memory_order_relaxed); } +}; + +} // namespace d2bs::runtime::events diff --git a/src/runtime/components/gameloop/GameLoop.cpp b/src/runtime/components/gameloop/GameLoop.cpp index 97920d3..34069d6 100644 --- a/src/runtime/components/gameloop/GameLoop.cpp +++ b/src/runtime/components/gameloop/GameLoop.cpp @@ -11,6 +11,7 @@ #include "components/drawing/Drawable.h" #include "components/drawing/VersionBanner.h" #include "components/events/EventDispatch.h" +#include "components/events/FrameCounter.h" #include "components/profile/ProfileService.h" #include "components/script/Script.h" #include "components/script/ScriptEngine.h" @@ -209,6 +210,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::FrameCounter::Increment(); 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..fa392f4 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/FrameCounter.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(); } + FireRenderEventIfNewFrame(); + // 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,23 @@ void Script::ExecuteEvents(std::chrono::milliseconds duration) { } } +void Script::FireRenderEventIfNewFrame() { + // A render handler that calls delay() runs this event loop again, and a frame drawn during that delay would + // fire render inside itself, nesting deeper every frame. Each script has its own thread, so this is per script. + static thread_local bool isInRenderHandler = false; + if (isInRenderHandler) { + return; + } + const auto current = events::FrameCounter::Current(); + if (current <= lastRenderFrame_ || !IsEventRegistered(events::RenderEvent::EVENT_NAME)) { + return; + } + lastRenderFrame_ = current; + isInRenderHandler = true; + const utils::DeferGuard leaveHandler([] { isInRenderHandler = false; }); + ExecuteEvent(std::make_shared(current)); +} + 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..b32e5af 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