Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/game_thread_safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/contract/game/GameCallbacks.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
24 changes: 24 additions & 0 deletions src/runtime/components/events/Events.h
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,30 @@ class BroadcastEvent : public BaseEvent {
std::vector<std::vector<uint8_t>> 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<v8::Local<v8::Value>> MakeArgs(v8::Isolate* isolate) const override {
return {api::convert::ToJS(isolate, static_cast<double>(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
// ============================================================================
Expand Down
22 changes: 22 additions & 0 deletions src/runtime/components/events/FrameCounter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include <atomic>
#include <cstdint>

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<uint64_t> 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
2 changes: 2 additions & 0 deletions src/runtime/components/gameloop/GameLoop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <version>" corner banner (carries the update-available
// marker). Drawn after script drawables so it stays on top.
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/gameloop/GameLoop.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion src/runtime/components/script/Script.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -73,7 +74,8 @@ Script::Script(std::filesystem::path path, ScriptMode mode, std::vector<std::vec
idle_(IDLE_TIMELINE),
mode_(mode),
args_(std::move(args)),
logger_(utils::GetLogger(path_.filename().string())) {}
logger_(utils::GetLogger(path_.filename().string())),
lastRenderFrame_(events::FrameCounter::Current()) {}

std::shared_ptr<spdlog::logger> GetLogger(v8::Isolate* isolate) {
if (auto* script = ScriptEngine::Instance().GetScript(isolate)) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<events::RenderEvent>(current));
}

bool Script::ExecuteEvent(const std::shared_ptr<events::BaseEvent>& event) {
auto* iso = isolate_.load().get();
if (!iso)
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/components/script/Script.h
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ class Script : public std::enable_shared_from_this<Script> {
// Drops every handler and its listener counts. Caller holds eventFunctionsMutex_.
void ClearEventFunctionsLocked();

// Script thread, each event-loop pass. Fires the "render" listeners once if a frame was rendered
// since they last fired (see events::FrameCounter).
void FireRenderEventIfNewFrame();

void ThreadMain(const std::stop_token& stopToken);
void SetupIsolate();
void TeardownIsolate();
Expand All @@ -249,6 +253,8 @@ class Script : public std::enable_shared_from_this<Script> {
std::vector<std::vector<uint8_t>> args_;
std::atomic<ScriptState> state_{ScriptState::Stopped};
std::shared_ptr<spdlog::logger> logger_;
// The frame this script last fired "render" for. Script-thread-only.
uint64_t lastRenderFrame_;

std::jthread thread_;
// Shared ownership prevents use-after-dispose: Stop() and PostEvent() (called
Expand Down
1 change: 1 addition & 0 deletions src/runtime/runtime.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
<ClInclude Include="components\events\DelayedEvent.h" />
<ClInclude Include="components\events\Events.h" />
<ClInclude Include="components\events\EventDispatch.h" />
<ClInclude Include="components\events\FrameCounter.h" />
<!-- Console -->
<ClInclude Include="components\console\ConsoleSink.h" />
<ClInclude Include="components\console\Console.h" />
Expand Down
13 changes: 13 additions & 0 deletions tests/runtime/events/FrameCounterTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#include <doctest/doctest.h>

#include "components/events/FrameCounter.h"

using d2bs::runtime::events::FrameCounter;

TEST_CASE("FrameCounter counts rendered frames") {
const auto before = FrameCounter::Current();
FrameCounter::Increment();
CHECK(FrameCounter::Current() == before + 1);
FrameCounter::Increment();
CHECK(FrameCounter::Current() == before + 2);
}
2 changes: 2 additions & 0 deletions tests/runtime/fakes/GameLoopCollaborators.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "components/drawing/Drawable.h"
#include "components/drawing/VersionBanner.h"
#include "components/events/EventDispatch.h"
#include "components/events/FrameCounter.h"
#include "components/script/ScriptEngine.h"
#include "config/AppConfig.h"

Expand Down Expand Up @@ -43,6 +44,7 @@ void Drawable::DrawAll(game::GameState state) {
auto& s = test::State();
++s.drawAllCount;
s.lastDrawState = state;
s.frameAtDraw = events::FrameCounter::Current();
}

// GameLoop::OnDraw also draws the version banner; the real implementation pulls
Expand Down
2 changes: 2 additions & 0 deletions tests/runtime/fakes/GameLoopCollaborators.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct GameLoopState {
std::vector<uint32_t> playerAssignEvents;
int32_t exitGameCount = 0;
int32_t drawAllCount = 0;
// FrameCounter::Current() when DrawAll last ran.
uint64_t frameAtDraw = 0;
std::optional<d2bs::game::GameState> lastDrawState;
};

Expand Down
19 changes: 19 additions & 0 deletions tests/runtime/gameloop/GameLoopTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <system_error>
#include <thread>

#include "components/events/FrameCounter.h"
#include "components/gameloop/GameLoop.h"
#include "components/profile/ProfileService.h"
#include "components/script/Commands.h"
Expand All @@ -21,6 +22,7 @@
#include "utils/utils.h"

using d2bs::game::GameState;
using d2bs::runtime::events::FrameCounter;
using d2bs::runtime::gameloop::GameLoop;

namespace {
Expand Down Expand Up @@ -258,6 +260,23 @@ TEST_CASE_FIXTURE(GameLoopFixture, "OnDraw paints drawables with most recently o
// NOLINTEND(bugprone-unchecked-optional-access)
}

TEST_CASE_FIXTURE(GameLoopFixture, "OnDraw counts the render frame once per frame, before the drawables") {
auto& s = d2bs::test::State();

GameLoop::Instance().OnSleep(std::chrono::milliseconds{0});
const auto before = FrameCounter::Current();

GameLoop::Instance().OnDraw();
CHECK(FrameCounter::Current() == before + 1);
CHECK(s.frameAtDraw == before + 1);
CHECK(s.drawAllCount == 1);

GameLoop::Instance().OnDraw();
CHECK(FrameCounter::Current() == before + 2);
CHECK(s.frameAtDraw == before + 2);
CHECK(s.drawAllCount == 2);
}

TEST_CASE_FIXTURE(GameLoopFixture, "waitForProfile disables script lifecycle") {
auto& cfg = d2bs::core::config::GetAppConfig();
cfg.waitForProfile.store(true);
Expand Down
2 changes: 2 additions & 0 deletions tests/runtime/js_tests.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@
<ClCompile Include="api\LockedHandleTest.cpp" />
<!-- GameLoop tests -->
<ClCompile Include="gameloop\GameLoopTest.cpp" />
<!-- Render event coalescing tests -->
<ClCompile Include="events\FrameCounterTest.cpp" />
</ItemGroup>
<PropertyGroup Label="Vcpkg">
<VcpkgEnabled>true</VcpkgEnabled>
Expand Down
Loading