From d84b32fc17b4e8112eba8d2ddf802d0e132c6344 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 3 Oct 2026 20:57:07 -0500 Subject: [PATCH] Encode every rendered frame of a replay export Videos exported from the replay editor played two to three times too fast unless the frame rate was capped, or the export was slow enough to hold it down by itself (4K, WebM). The fault is in the game's own export path. Replay export (Extension/Rendering/replay_export.*) - While exporting, the game steps the replay by one frame of video (speed / fps) for every frame it renders. The engine's movie encoder takes a frame only while a permit byte is set; a world update sets it once, and a frame submitted without it is dropped. Rendering two or three frames per update, most of a clip never reached the file, and an MP4 is exactly as long as its frame count. One clip came out as 181 frames at 30 fps and 297 at 60, 6.0 s and 5.0 s. - The encoder's frame submit is detoured and the permit set before the original runs, for the replay editor's encoder only (matched by its vtable). The frame is encoded on the render thread before the call returns, so the renderer is held to the pace the encoder keeps and no frame is lost. The replay still runs fast on screen during an export: that is the encoder's speed, not the file's. - The same clip afterwards, frame rate uncapped, MP4 1080p: 505 frames at 30 fps and 1008 at 60, 16.8 s both, audio as long as the video. WebM and 4K take the same path and were not run again. - Addresses are in Engine/Game/Build/20260929/replay_export.h. The hook starts with the others in runtime_init.cpp. --- Engine/Game/Build/20260929/replay_export.h | 19 +++++++++ Extension/Rendering/replay_export.cpp | 48 ++++++++++++++++++++++ Extension/Rendering/replay_export.h | 11 +++++ Runtime/runtime_init.cpp | 3 ++ cmake/Runtime.cmake | 1 + 5 files changed, 82 insertions(+) create mode 100644 Engine/Game/Build/20260929/replay_export.h create mode 100644 Extension/Rendering/replay_export.cpp create mode 100644 Extension/Rendering/replay_export.h diff --git a/Engine/Game/Build/20260929/replay_export.h b/Engine/Game/Build/20260929/replay_export.h new file mode 100644 index 0000000..560d936 --- /dev/null +++ b/Engine/Game/Build/20260929/replay_export.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +// The replay editor's video export (analysis/replay-editor.md). +namespace dingosdk::game::build::v20260929::replay_export { +// The engine movie encoder's frame submit: (encoder, pixels, pitch). The render +// thread calls it at the end of every rendered frame while a capture runs, and +// it encodes the frame, video and its share of audio, before it returns. +inline constexpr std::uintptr_t submit_frame = 0x34f4610; +inline constexpr std::array submit_frame_prefix{ + 0x40,0x57,0x48,0x81,0xec,0x50,0x08,0x00,0x00,0x48,0x8b,0x05,0xa0,0xfd,0xcc,0x03, + 0x48,0x33,0xc4,0x48,0x89,0x84,0x24,0x20}; +// The encoder the replay editor exports with; the engine has others. +inline constexpr std::uintptr_t replay_encoder_vtable = 0x608c0b0; +// Encoder byte: a frame may be encoded. A world update sets it and an encoded +// frame clears it; submit_frame drops the frame it is called with while clear. +inline constexpr std::uintptr_t frame_permit = 0x90; +} diff --git a/Extension/Rendering/replay_export.cpp b/Extension/Rendering/replay_export.cpp new file mode 100644 index 0000000..158b6b8 --- /dev/null +++ b/Extension/Rendering/replay_export.cpp @@ -0,0 +1,48 @@ +#include "replay_export.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/replay_export.h" +#include +#include + +namespace dingosdk::replay_export { +namespace { +namespace native = addr::replay_export; +// The encoder passes its arguments straight on to the codec, so all four +// register arguments are forwarded as they came. +using Submit = void (*)(std::uintptr_t encoder, std::uintptr_t pixels, std::uintptr_t pitch, std::uintptr_t unused); +std::atomic original{}; +std::atomic image{}; + +void submit(std::uintptr_t encoder, std::uintptr_t pixels, std::uintptr_t pitch, std::uintptr_t unused) { + // Encoding is done on this thread before the call returns, so letting every + // frame through also holds the renderer to the pace the encoder keeps. + std::uintptr_t vtable{}; + if (memory::peek(encoder, vtable) && vtable == image.load() + native::replay_encoder_vtable) + *reinterpret_cast(encoder + native::frame_permit) = 1; + original.load()(encoder, pixels, pitch, unused); +} +} // namespace + +bool start(std::uintptr_t base) noexcept { + try { + std::array bytes{}; + if (!base || !memory::read_bytes(base + native::submit_frame, bytes.data(), bytes.size()) || + bytes != native::submit_frame_prefix) + return false; + image = base; + auto* target = reinterpret_cast(base + native::submit_frame); + void* previous{}; + if (hook_prepare(target, reinterpret_cast(&submit), &previous) != HookOk) return false; + original = reinterpret_cast(previous); + if (hook_enable(target) != HookOk) { + hook_remove(target); + return false; + } + return true; + } catch (...) { + return false; + } +} +} // namespace dingosdk::replay_export diff --git a/Extension/Rendering/replay_export.h b/Extension/Rendering/replay_export.h new file mode 100644 index 0000000..296ec21 --- /dev/null +++ b/Extension/Rendering/replay_export.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace dingosdk::replay_export { +// The replay editor steps the replay by one frame of video for every frame the +// game renders, but the engine encodes at most one frame per world update and +// drops the rest. Rendering faster than it updates (an uncapped frame rate), +// an export came out with a fraction of its frames and played that much too +// fast. Every rendered frame of a replay export is encoded instead. +bool start(std::uintptr_t base) noexcept; +} diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 568d910..06da626 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -33,6 +33,7 @@ #include "Extension/World/level_loading.h" #include "Extension/World/loading_screen.h" #include "Extension/Customization/preset_lookup_guard.h" +#include "Extension/Rendering/replay_export.h" #include "Engine/Vfs/content_catalogs.h" #include "Engine/Vfs/world_layer_scan.h" #include "Engine/Game/World/world_layer_catalog.h" @@ -372,6 +373,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { record("{\"event\":\"map_loading_screens_initialized\",\"active\":" + std::string(loading_screens ? "true" : "false") + "}"); const bool preset_guard = dingosdk::preset_lookup_guard::start(r.base); record("{\"event\":\"preset_lookup_guard_initialized\",\"active\":" + std::string(preset_guard ? "true" : "false") + "}"); + const bool replay_export = dingosdk::replay_export::start(r.base); + record("{\"event\":\"replay_export_initialized\",\"active\":" + std::string(replay_export ? "true" : "false") + "}"); void* original{}; auto* address = reinterpret_cast(r.base + rt::client_tick); auto hook_status = dingosdk::hook_prepare(address, reinterpret_cast(&tick), &original); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 190c429..c8da6eb 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -87,6 +87,7 @@ add_library(dingosdk_runtime SHARED Extension/Assets/native_render_resource_pool.cpp Extension/World/native_entity_pages.cpp Extension/Rendering/display_startup.cpp + Extension/Rendering/replay_export.cpp Engine/Game/World/world_model.cpp Extension/World/level_loading.cpp Extension/World/loading_screen.cpp