mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 12:57:01 +02:00
1.0.0 Release!
This commit is contained in:
1 parent
706e191fbd
commit
ca1d82f636
1051 files changed
+607895
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Vendor hashes cover exact upstream bytes, including nested headers.
|
||||
External/rapidjson/** -text
|
||||
External/rapidjson/** whitespace=-blank-at-eol
|
||||
|
||||
External/detours/** -text
|
||||
External/detours/** whitespace=-blank-at-eol
|
||||
|
||||
External/imgui/** -text
|
||||
External/imgui/** whitespace=-blank-at-eol
|
||||
|
||||
External/spdlog/** -text
|
||||
|
||||
External/steam_networking/** -text
|
||||
External/steam_networking/** whitespace=-blank-at-eol
|
||||
|
||||
External/miniz/** -text
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Build ReSkate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version stamped into the binaries (e.g. 0.2.0)
|
||||
required: true
|
||||
default: 0.0.0-dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Windows x64 Release
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure
|
||||
env:
|
||||
RESKATE_BACKTRACE_URL: ${{ secrets.RESKATE_BACKTRACE_URL }}
|
||||
run: cmake --preset vs2022-x64 -DDINGOSDK_LAUNCHER_AUTO_UPDATE=ON '-DDINGOSDK_VERSION=${{ inputs.version }}'
|
||||
|
||||
- name: Build launcher and runtime
|
||||
run: cmake --build --preset release --parallel 4 --target dingosdk_launcher dingosdk_runtime
|
||||
|
||||
- name: Stage package
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$package = 'build/package'
|
||||
New-Item -ItemType Directory -Path "$package/licenses" -Force | Out-Null
|
||||
Copy-Item -LiteralPath 'build/vs2022-x64/Release/ReSkateLauncher.exe' -Destination $package
|
||||
Copy-Item -LiteralPath 'build/vs2022-x64/Release/ReSkate.dll' -Destination $package
|
||||
$symbols = 'build/symbols'
|
||||
New-Item -ItemType Directory -Path $symbols -Force | Out-Null
|
||||
foreach ($name in @('ReSkate', 'ReSkateLauncher')) {
|
||||
Copy-Item -LiteralPath "build/vs2022-x64/Release/$name.pdb" -Destination $symbols
|
||||
}
|
||||
Copy-Item -LiteralPath 'build/vs2022-x64/Release/ReSkate.dll' -Destination $symbols
|
||||
Copy-Item -LiteralPath 'build/vs2022-x64/Release/ReSkateLauncher.exe' -Destination $symbols
|
||||
Copy-Item -LiteralPath 'LICENSE' -Destination "$package/LICENSE.txt"
|
||||
foreach ($library in @('imgui', 'detours', 'rapidjson', 'sqlite', 'miniz', 'bcdec')) {
|
||||
Copy-Item -LiteralPath "External/$library/LICENSE.txt" -Destination "$package/licenses/$library-LICENSE.txt"
|
||||
}
|
||||
foreach ($library in @('spdlog', 'lz4', 'zstd', 'steam_networking')) {
|
||||
Copy-Item -LiteralPath "External/$library/LICENSE" -Destination "$package/licenses/$library-LICENSE.txt"
|
||||
}
|
||||
Copy-Item -Path 'External/fonts/*-LICENSE.txt' -Destination "$package/licenses"
|
||||
|
||||
- name: Upload ReSkate ZIP
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ReSkate-windows-x64-${{ github.sha }}
|
||||
path: build/package/
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload matching debug symbols
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ReSkate-symbols-${{ github.sha }}
|
||||
path: build/symbols/
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/build/
|
||||
/out/
|
||||
/artifacts/
|
||||
/logs/
|
||||
/.vs/
|
||||
__pycache__/
|
||||
*.log
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.lib
|
||||
*.exp
|
||||
*.keys
|
||||
*.pcap
|
||||
*.pcapng
|
||||
*.pdml
|
||||
*.mitm
|
||||
analysis/raw/**/*.ebx
|
||||
analysis/raw/**/*.res
|
||||
|
||||
# Optional developer utilities stay local. CMake is self-contained.
|
||||
/tools/
|
||||
/tests/
|
||||
|
||||
# Local research and extended documentation are not shipped.
|
||||
/analysis/
|
||||
/docs/
|
||||
/release
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(DingoSDK LANGUAGES CXX)
|
||||
if(NOT WIN32 OR NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
message(FATAL_ERROR "DingoSDK requires Windows, MSVC and the x64 platform.")
|
||||
endif()
|
||||
|
||||
include(cmake/ProjectOptions.cmake)
|
||||
include(cmake/Dependencies.cmake)
|
||||
include(cmake/CustomScriptData.cmake)
|
||||
include(cmake/Core.cmake)
|
||||
include(cmake/Features.cmake)
|
||||
include(cmake/UI.cmake)
|
||||
include(cmake/Runtime.cmake)
|
||||
include(cmake/Apps.cmake)
|
||||
include(cmake/Backtrace.cmake)
|
||||
|
||||
option(DINGOSDK_BUILD_TESTS "Build logging regression tests" OFF)
|
||||
option(DINGOSDK_BUILD_PARK_EDITOR_TESTS "Build park editor regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_PARK_EDITOR_TESTS)
|
||||
enable_testing()
|
||||
add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp)
|
||||
target_link_libraries(dingosdk_park_editor_tests PRIVATE dingosdk_local_profile)
|
||||
add_test(NAME park_editor COMMAND dingosdk_park_editor_tests "${CMAKE_CURRENT_BINARY_DIR}/editor-fixtures")
|
||||
add_executable(dingosdk_park_editor_ui_tests Extension/UI/Overlay/Test/park_editor_tests.cpp
|
||||
Extension/UI/Overlay/park_editor.cpp Extension/UI/Overlay/park_editor_actions.cpp
|
||||
Extension/UI/Overlay/park_editor_panels.cpp Extension/UI/Overlay/park_editor_viewport.cpp Extension/UI/Overlay/park_previews.cpp)
|
||||
target_link_libraries(dingosdk_park_editor_ui_tests PRIVATE dingosdk_imgui dingosdk_json dingosdk_game_archives dingosdk_content_cache dingosdk_logging)
|
||||
add_test(NAME park_editor_ui COMMAND dingosdk_park_editor_ui_tests "${CMAKE_CURRENT_BINARY_DIR}/editor-preview" "${DINGOSDK_TEST_GAME_ROOT}")
|
||||
add_executable(dingosdk_park_editor_transaction_tests Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp)
|
||||
target_link_libraries(dingosdk_park_editor_transaction_tests PRIVATE dingosdk_local_profile dingosdk_logging)
|
||||
add_test(NAME park_editor_transactions COMMAND dingosdk_park_editor_transaction_tests)
|
||||
add_executable(dingosdk_profile_migration_tests Extension/Profile/Test/profile_migration_tests.cpp)
|
||||
target_link_libraries(dingosdk_profile_migration_tests PRIVATE dingosdk_local_profile)
|
||||
add_test(NAME profile_migration COMMAND dingosdk_profile_migration_tests)
|
||||
add_executable(dingosdk_object_categories_tests Extension/Objects/Test/object_categories_tests.cpp)
|
||||
target_link_libraries(dingosdk_object_categories_tests PRIVATE dingosdk_local_profile)
|
||||
add_test(NAME object_categories COMMAND dingosdk_object_categories_tests)
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_MULTIPLAYER_TESTS "Build multiplayer protocol regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
|
||||
enable_testing()
|
||||
add_executable(dingosdk_voice_tests Extension/Multiplayer/Test/voice_chat_tests.cpp Extension/Multiplayer/Net/protocol.cpp
|
||||
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp)
|
||||
target_link_libraries(dingosdk_voice_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
|
||||
add_test(NAME multiplayer_voice COMMAND dingosdk_voice_tests)
|
||||
if(EXISTS "${PROJECT_SOURCE_DIR}/tests/first_person_spring_tests.cpp")
|
||||
add_executable(dingosdk_first_person_spring_tests tests/first_person_spring_tests.cpp)
|
||||
target_include_directories(dingosdk_first_person_spring_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME first_person_spring COMMAND dingosdk_first_person_spring_tests)
|
||||
endif()
|
||||
add_executable(dingosdk_playstation_report_tests Engine/Game/Input/Test/playstation_report_tests.cpp)
|
||||
target_include_directories(dingosdk_playstation_report_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME playstation_report COMMAND dingosdk_playstation_report_tests)
|
||||
add_executable(dingosdk_physics_tuning_tests Extension/Skater/Test/physics_tuning_tests.cpp
|
||||
Extension/Skater/physics_tuning_model.cpp)
|
||||
target_include_directories(dingosdk_physics_tuning_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_physics_tuning_tests PRIVATE dingosdk_game_archives)
|
||||
add_test(NAME physics_tuning COMMAND dingosdk_physics_tuning_tests "${DINGOSDK_TEST_GAME_ROOT}")
|
||||
add_executable(dingosdk_camera_observer_tests Extension/Skater/Test/camera_observer_tests.cpp)
|
||||
add_test(NAME camera_observer_recovery COMMAND dingosdk_camera_observer_tests)
|
||||
add_executable(dingosdk_native_menu_view_tests Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp)
|
||||
add_executable(dingosdk_loading_screen_tests Extension/World/Test/loading_screen_tests.cpp)
|
||||
target_include_directories(dingosdk_loading_screen_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME loading_screen_selection COMMAND dingosdk_loading_screen_tests)
|
||||
add_test(NAME native_menu_browser COMMAND dingosdk_native_menu_view_tests)
|
||||
add_executable(dingosdk_native_tools_view_tests Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp)
|
||||
target_include_directories(dingosdk_native_tools_view_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME native_menu_tools COMMAND dingosdk_native_tools_view_tests)
|
||||
add_executable(dingosdk_native_menu_lifetime_tests Extension/UI/NativeMenu/Test/native_menu_lifetime_tests.cpp)
|
||||
add_test(NAME native_menu_lifetime COMMAND dingosdk_native_menu_lifetime_tests)
|
||||
add_executable(dingosdk_native_callback_tests Engine/Game/Abi/Test/native_callback_queue_tests.cpp)
|
||||
target_include_directories(dingosdk_native_callback_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME native_callback_lifetime COMMAND dingosdk_native_callback_tests)
|
||||
add_executable(dingosdk_steam_friend_join_tests Extension/Multiplayer/Test/steam_friend_join_tests.cpp)
|
||||
add_test(NAME steam_friend_join COMMAND dingosdk_steam_friend_join_tests)
|
||||
add_executable(dingosdk_multiplayer_tests
|
||||
Extension/Multiplayer/Test/protocol_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp Server/steam_server.cpp
|
||||
Extension/Throwdowns/throwdown_wire.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter)
|
||||
add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests)
|
||||
add_executable(dingosdk_party_tests Extension/Multiplayer/Test/party_tests.cpp Extension/Multiplayer/Session/party_book.cpp)
|
||||
target_include_directories(dingosdk_party_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME multiplayer_parties COMMAND dingosdk_party_tests)
|
||||
add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp
|
||||
Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp)
|
||||
target_include_directories(dingosdk_throwdown_relay_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_throwdown_relay_tests PRIVATE dingosdk_logging)
|
||||
add_test(NAME throwdown_relay COMMAND dingosdk_throwdown_relay_tests)
|
||||
add_executable(dingosdk_server_activity_tests Server/Test/server_activity_tests.cpp Server/server_activity.cpp
|
||||
Extension/Throwdowns/throwdown_wire.cpp)
|
||||
target_include_directories(dingosdk_server_activity_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME server_activity COMMAND dingosdk_server_activity_tests)
|
||||
add_executable(dingosdk_speed_check_tests Server/Test/speed_check_tests.cpp)
|
||||
target_include_directories(dingosdk_speed_check_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME server_speed_check COMMAND dingosdk_speed_check_tests)
|
||||
add_executable(dingosdk_server_config_tests Server/Test/server_config_tests.cpp Server/server_config.cpp
|
||||
Extension/Multiplayer/Net/protocol.cpp)
|
||||
target_include_directories(dingosdk_server_config_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_server_config_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
|
||||
dingosdk_lz4 dingosdk_zstd dingosdk_logging)
|
||||
add_test(NAME server_config COMMAND dingosdk_server_config_tests)
|
||||
add_executable(dingosdk_multiplayer_native_tests
|
||||
Extension/Multiplayer/Test/native_layout_tests.cpp Extension/Multiplayer/Net/protocol.cpp)
|
||||
add_test(NAME multiplayer_native_layout COMMAND dingosdk_multiplayer_native_tests)
|
||||
add_executable(dingosdk_multiplayer_lobby_tests
|
||||
Extension/Multiplayer/Test/lobby_tests.cpp Extension/Multiplayer/Steam/steam_lobbies.cpp
|
||||
Extension/Multiplayer/Net/protocol.cpp)
|
||||
add_test(NAME multiplayer_lobbies COMMAND dingosdk_multiplayer_lobby_tests)
|
||||
add_executable(dingosdk_multiplayer_mesh_tests
|
||||
Extension/Multiplayer/Test/mesh_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp
|
||||
Extension/Multiplayer/Session/password.cpp Extension/Multiplayer/Steam/steam_lobbies.cpp
|
||||
Extension/Multiplayer/Steam/steam_server_browser.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_mesh_tests PRIVATE dingosdk_logging dingosdk_launcher_support dingosdk_lz4 dingosdk_zstd bcrypt
|
||||
dingosdk_word_filter)
|
||||
add_test(NAME multiplayer_mesh COMMAND dingosdk_multiplayer_mesh_tests)
|
||||
add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp)
|
||||
target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter)
|
||||
add_test(NAME word_filter COMMAND dingosdk_word_filter_tests)
|
||||
add_executable(dingosdk_settings_lock_tests Engine/Game/Settings/Test/multiplayer_settings_lock_tests.cpp)
|
||||
target_include_directories(dingosdk_settings_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests)
|
||||
add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp)
|
||||
add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests)
|
||||
add_executable(dingosdk_multiplayer_bandwidth_tests
|
||||
Extension/Multiplayer/Test/bandwidth_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_bandwidth_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
|
||||
add_test(NAME multiplayer_bandwidth COMMAND dingosdk_multiplayer_bandwidth_tests)
|
||||
add_executable(dingosdk_multiplayer_lane_tests Extension/Multiplayer/Test/steam_lanes_tests.cpp)
|
||||
target_include_directories(dingosdk_multiplayer_lane_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
|
||||
add_test(NAME multiplayer_lanes COMMAND dingosdk_multiplayer_lane_tests)
|
||||
add_executable(dingosdk_multiplayer_prediction_tests
|
||||
Extension/Multiplayer/Test/prediction_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp)
|
||||
add_test(NAME multiplayer_prediction COMMAND dingosdk_multiplayer_prediction_tests)
|
||||
endif()
|
||||
# The route and logging tests live in the local, untracked tests/ folder.
|
||||
option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_ROUTE_TESTS AND EXISTS "${PROJECT_SOURCE_DIR}/tests/route_cycle_tests.cpp")
|
||||
enable_testing()
|
||||
add_executable(dingosdk_route_cycle_tests tests/route_cycle_tests.cpp)
|
||||
target_include_directories(dingosdk_route_cycle_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME route_cycle_guard COMMAND dingosdk_route_cycle_tests)
|
||||
add_executable(dingosdk_route_endpoint_tests tests/route_endpoint_tests.cpp)
|
||||
target_link_libraries(dingosdk_route_endpoint_tests PRIVATE dingosdk_hooks)
|
||||
add_test(NAME route_endpoint_guard COMMAND dingosdk_route_endpoint_tests)
|
||||
endif()
|
||||
if(DINGOSDK_BUILD_TESTS AND EXISTS "${PROJECT_SOURCE_DIR}/tests/logging_tests.cpp")
|
||||
enable_testing()
|
||||
add_executable(dingosdk_logging_tests tests/logging_tests.cpp)
|
||||
target_link_libraries(dingosdk_logging_tests PRIVATE dingosdk_logging)
|
||||
add_test(NAME logging_regressions COMMAND dingosdk_logging_tests)
|
||||
endif()
|
||||
|
||||
# Header-only game contracts belong to the runtime's subsystem tree.
|
||||
file(GLOB_RECURSE game_headers CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Game/*.h")
|
||||
target_sources(dingosdk_runtime PRIVATE ${game_headers})
|
||||
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
|
||||
foreach(target IN LISTS project_targets)
|
||||
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz)$")
|
||||
dingosdk_configure_target(${target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "vs2022-x64",
|
||||
"displayName": "Visual Studio 2022 x64",
|
||||
"generator": "Visual Studio 17 2022",
|
||||
"architecture": "x64",
|
||||
"binaryDir": "${sourceDir}/build/vs2022-x64",
|
||||
"environment": {
|
||||
"VCLibPackagePath": "${sourceDir}/build/no-vcpkg/vcpkg.user"
|
||||
},
|
||||
"toolset": "v143"
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "release",
|
||||
"configurePreset": "vs2022-x64",
|
||||
"configuration": "Release"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// ReSkateEmotePacker: builds the chat emote pack ReSkate.dll bakes in (assets/emotes) from a
|
||||
// folder of pictures and GIFs. Every file becomes one emote named after the file; animated
|
||||
// GIFs keep their frames and timing. Output: emotes.json + emotes.png in Better Chat's format
|
||||
// (Extension/UI/Overlay/chat_emotes.h), within the limits ReSkate's loader accepts.
|
||||
//
|
||||
// ReSkateEmotePacker <folder> [output folder] [--size <px>] [--max-frames <n>]
|
||||
// [--max-width <px>] [--name <pack name>]
|
||||
//
|
||||
// Dropping a folder on the exe writes the pack next to it, in "<folder>_pack".
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <Windows.h>
|
||||
#include <wincodec.h>
|
||||
#include <wrl/client.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using Microsoft::WRL::ComPtr;
|
||||
namespace fs = std::filesystem;
|
||||
using dingosdk::Json;
|
||||
|
||||
namespace {
|
||||
// ReSkate's loader limits (chat_emotes.cpp): frames at most 256 x 256, names of letters,
|
||||
// digits and _ up to 32 characters, 4096 frames in all, a texture at most 8192 x 8192.
|
||||
constexpr int max_side = 256, max_texture = 8192;
|
||||
constexpr std::size_t max_name = 32, max_total_frames = 4096;
|
||||
|
||||
struct Options {
|
||||
fs::path input, output;
|
||||
int size = 56; // emote height in pixels (wide emotes may be shorter)
|
||||
int max_width = 2048; // atlas width
|
||||
std::size_t max_frames = 64; // per animated emote; longer GIFs are thinned evenly
|
||||
std::string name = "ReSkate";
|
||||
};
|
||||
struct Image {
|
||||
int width{}, height{};
|
||||
std::vector<unsigned char> rgba; // straight alpha
|
||||
};
|
||||
struct Frame {
|
||||
Image image;
|
||||
unsigned time{}; // ms
|
||||
int x{}, y{}; // in the atlas
|
||||
};
|
||||
struct Emote {
|
||||
std::string name, file;
|
||||
std::vector<Frame> frames;
|
||||
};
|
||||
|
||||
void check(HRESULT result, const char *what) {
|
||||
if (FAILED(result)) {
|
||||
char text[160];
|
||||
std::snprintf(text, sizeof text, "%s failed (0x%08lx)", what, static_cast<unsigned long>(result));
|
||||
throw std::runtime_error(text);
|
||||
}
|
||||
}
|
||||
// Kept for the life of the process: releasing it after COM has shut down would crash on exit.
|
||||
IWICImagingFactory &factory() {
|
||||
static IWICImagingFactory *value = [] {
|
||||
IWICImagingFactory *f{};
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&f)), "Starting Windows Imaging");
|
||||
return f;
|
||||
}();
|
||||
return *value;
|
||||
}
|
||||
Image to_rgba(IWICBitmapSource *source) {
|
||||
ComPtr<IWICFormatConverter> converter;
|
||||
check(factory().CreateFormatConverter(&converter), "Creating a format converter");
|
||||
check(converter->Initialize(source, GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, nullptr, 0,
|
||||
WICBitmapPaletteTypeCustom), "Converting to RGBA");
|
||||
UINT w{}, h{};
|
||||
check(converter->GetSize(&w, &h), "Reading the size");
|
||||
Image image{static_cast<int>(w), static_cast<int>(h)};
|
||||
image.rgba.resize(static_cast<std::size_t>(w) * h * 4);
|
||||
check(converter->CopyPixels(nullptr, w * 4, static_cast<UINT>(image.rgba.size()), image.rgba.data()), "Reading pixels");
|
||||
return image;
|
||||
}
|
||||
std::optional<unsigned> metadata_number(IWICMetadataQueryReader *reader, const wchar_t *path) {
|
||||
PROPVARIANT value;
|
||||
PropVariantInit(&value);
|
||||
std::optional<unsigned> result;
|
||||
if (reader && SUCCEEDED(reader->GetMetadataByName(path, &value))) {
|
||||
if (value.vt == VT_UI1) result = value.bVal;
|
||||
else if (value.vt == VT_UI2) result = value.uiVal;
|
||||
else if (value.vt == VT_UI4) result = value.ulVal;
|
||||
}
|
||||
PropVariantClear(&value);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Every frame of a picture as it shows: a GIF's frames are composited onto its canvas with
|
||||
// their offsets, transparency and disposal, as a browser plays them. Other formats: frame 1.
|
||||
std::vector<Frame> decode(const fs::path &file) {
|
||||
ComPtr<IWICBitmapDecoder> decoder;
|
||||
check(factory().CreateDecoderFromFilename(file.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder),
|
||||
"Opening the picture");
|
||||
GUID container{};
|
||||
decoder->GetContainerFormat(&container);
|
||||
UINT count{};
|
||||
check(decoder->GetFrameCount(&count), "Counting frames");
|
||||
std::vector<Frame> frames;
|
||||
if (container != GUID_ContainerFormatGif || count <= 1) {
|
||||
ComPtr<IWICBitmapFrameDecode> frame;
|
||||
check(decoder->GetFrame(0, &frame), "Reading the picture");
|
||||
frames.push_back({to_rgba(frame.Get()), 0});
|
||||
return frames;
|
||||
}
|
||||
ComPtr<IWICMetadataQueryReader> global;
|
||||
decoder->GetMetadataQueryReader(&global);
|
||||
const int width = static_cast<int>(metadata_number(global.Get(), L"/logscrdesc/Width").value_or(0));
|
||||
const int height = static_cast<int>(metadata_number(global.Get(), L"/logscrdesc/Height").value_or(0));
|
||||
if (width <= 0 || height <= 0 || width > 4096 || height > 4096) throw std::runtime_error("the GIF has no usable canvas size");
|
||||
std::vector<unsigned char> canvas(static_cast<std::size_t>(width) * height * 4, 0), saved;
|
||||
struct Rect { int x{}, y{}, w{}, h{}; } previous;
|
||||
unsigned previous_disposal{};
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
ComPtr<IWICBitmapFrameDecode> frame;
|
||||
check(decoder->GetFrame(i, &frame), "Reading a GIF frame");
|
||||
ComPtr<IWICMetadataQueryReader> reader;
|
||||
frame->GetMetadataQueryReader(&reader);
|
||||
const auto piece = to_rgba(frame.Get());
|
||||
const Rect rect{static_cast<int>(metadata_number(reader.Get(), L"/imgdesc/Left").value_or(0)),
|
||||
static_cast<int>(metadata_number(reader.Get(), L"/imgdesc/Top").value_or(0)), piece.width, piece.height};
|
||||
const auto delay = metadata_number(reader.Get(), L"/grctlext/Delay").value_or(10);
|
||||
const auto disposal = metadata_number(reader.Get(), L"/grctlext/Disposal").value_or(0);
|
||||
// The previous frame leaves the canvas as its disposal says.
|
||||
if (i > 0 && previous_disposal == 2)
|
||||
for (int y = std::max(previous.y, 0); y < std::min(previous.y + previous.h, height); ++y)
|
||||
for (int x = std::max(previous.x, 0); x < std::min(previous.x + previous.w, width); ++x)
|
||||
std::fill_n(canvas.begin() + (static_cast<std::size_t>(y) * width + x) * 4, 4, static_cast<unsigned char>(0));
|
||||
else if (i > 0 && previous_disposal == 3 && !saved.empty())
|
||||
canvas = saved;
|
||||
if (disposal == 3) saved = canvas;
|
||||
for (int y = 0; y < piece.height; ++y)
|
||||
for (int x = 0; x < piece.width; ++x) {
|
||||
const int cx = rect.x + x, cy = rect.y + y;
|
||||
if (cx < 0 || cy < 0 || cx >= width || cy >= height) continue;
|
||||
const auto *src = piece.rgba.data() + (static_cast<std::size_t>(y) * piece.width + x) * 4;
|
||||
if (src[3] == 0) continue; // transparent pixels leave what is underneath
|
||||
std::copy_n(src, 4, canvas.begin() + (static_cast<std::size_t>(cy) * width + cx) * 4);
|
||||
}
|
||||
// Browsers show delays under 20 ms as 100 ms.
|
||||
frames.push_back({{width, height, canvas}, delay < 2 ? 100U : delay * 10U});
|
||||
previous = rect;
|
||||
previous_disposal = disposal;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
// Scaled to `height` (wide pictures to at most 256 wide), in premultiplied alpha so edges do
|
||||
// not darken.
|
||||
Image scale(const Image &image, int height) {
|
||||
int h = height, w = std::max(1, static_cast<int>(std::lround(static_cast<double>(image.width) * height / image.height)));
|
||||
if (w > max_side) {
|
||||
h = std::max(1, static_cast<int>(std::lround(static_cast<double>(h) * max_side / w)));
|
||||
w = max_side;
|
||||
}
|
||||
if (w == image.width && h == image.height) return image;
|
||||
ComPtr<IWICBitmap> bitmap;
|
||||
check(factory().CreateBitmapFromMemory(image.width, image.height, GUID_WICPixelFormat32bppRGBA, image.width * 4,
|
||||
static_cast<UINT>(image.rgba.size()), const_cast<BYTE *>(image.rgba.data()), &bitmap),
|
||||
"Loading pixels");
|
||||
ComPtr<IWICFormatConverter> premultiplied;
|
||||
check(factory().CreateFormatConverter(&premultiplied), "Creating a format converter");
|
||||
check(premultiplied->Initialize(bitmap.Get(), GUID_WICPixelFormat32bppPRGBA, WICBitmapDitherTypeNone, nullptr, 0,
|
||||
WICBitmapPaletteTypeCustom), "Premultiplying");
|
||||
ComPtr<IWICBitmapScaler> scaler;
|
||||
check(factory().CreateBitmapScaler(&scaler), "Creating a scaler");
|
||||
check(scaler->Initialize(premultiplied.Get(), w, h,
|
||||
w < image.width ? WICBitmapInterpolationModeFant : WICBitmapInterpolationModeHighQualityCubic),
|
||||
"Scaling");
|
||||
return to_rgba(scaler.Get());
|
||||
}
|
||||
|
||||
// Consecutive identical frames become one, showing for their combined time; more than
|
||||
// `limit` frames are thinned evenly, each kept frame showing for the ones it replaces.
|
||||
std::vector<Frame> tidy(std::vector<Frame> frames, std::size_t limit) {
|
||||
std::vector<Frame> merged;
|
||||
for (auto &frame : frames) {
|
||||
if (!merged.empty() && merged.back().image.rgba == frame.image.rgba) merged.back().time += frame.time;
|
||||
else merged.push_back(std::move(frame));
|
||||
}
|
||||
if (merged.size() <= limit) return merged;
|
||||
std::vector<Frame> thinned;
|
||||
for (std::size_t i = 0; i < limit; ++i) {
|
||||
const auto from = i * merged.size() / limit, to = (i + 1) * merged.size() / limit;
|
||||
Frame kept = std::move(merged[from]);
|
||||
for (auto j = from + 1; j < to; ++j) kept.time += merged[j].time;
|
||||
thinned.push_back(std::move(kept));
|
||||
}
|
||||
return thinned;
|
||||
}
|
||||
|
||||
std::string emote_name(const fs::path &file) {
|
||||
std::string name;
|
||||
for (const char c : file.stem().string())
|
||||
name += std::isalnum(static_cast<unsigned char>(c)) ? c : '_';
|
||||
if (name.size() > max_name) name.resize(max_name);
|
||||
return name.empty() ? std::string("emote") : name;
|
||||
}
|
||||
std::string lowered(std::string text) {
|
||||
for (auto &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return text;
|
||||
}
|
||||
|
||||
// Rows of frames across the atlas, tallest rows first.
|
||||
int pack(std::vector<Emote> &emotes, int width) {
|
||||
std::vector<Frame *> frames;
|
||||
for (auto &e : emotes)
|
||||
for (auto &f : e.frames) frames.push_back(&f);
|
||||
std::stable_sort(frames.begin(), frames.end(), [](const Frame *a, const Frame *b) { return a->image.height > b->image.height; });
|
||||
constexpr int gap = 1;
|
||||
int x = 0, y = 0, row = 0;
|
||||
for (auto *f : frames) {
|
||||
if (x > 0 && x + f->image.width > width) {
|
||||
x = 0;
|
||||
y += row + gap;
|
||||
row = 0;
|
||||
}
|
||||
f->x = x;
|
||||
f->y = y;
|
||||
x += f->image.width + gap;
|
||||
row = std::max(row, f->image.height);
|
||||
}
|
||||
return y + row;
|
||||
}
|
||||
|
||||
void write_png(const fs::path &file, int width, int height, const std::vector<unsigned char> &rgba) {
|
||||
// The PNG encoder takes BGRA everywhere.
|
||||
std::vector<unsigned char> bgra(rgba);
|
||||
for (std::size_t i = 0; i < bgra.size(); i += 4) std::swap(bgra[i], bgra[i + 2]);
|
||||
ComPtr<IWICStream> stream;
|
||||
check(factory().CreateStream(&stream), "Creating the output file");
|
||||
check(stream->InitializeFromFilename(file.c_str(), GENERIC_WRITE), "Opening the output file");
|
||||
ComPtr<IWICBitmapEncoder> encoder;
|
||||
check(factory().CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder), "Creating the PNG encoder");
|
||||
check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "Starting the PNG");
|
||||
ComPtr<IWICBitmapFrameEncode> frame;
|
||||
check(encoder->CreateNewFrame(&frame, nullptr), "Adding the PNG image");
|
||||
check(frame->Initialize(nullptr), "Starting the PNG image");
|
||||
check(frame->SetSize(width, height), "Sizing the PNG");
|
||||
WICPixelFormatGUID format = GUID_WICPixelFormat32bppBGRA;
|
||||
check(frame->SetPixelFormat(&format), "Choosing the PNG format");
|
||||
if (format != GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("the PNG encoder does not take 32-bit BGRA");
|
||||
check(frame->WritePixels(height, width * 4, static_cast<UINT>(bgra.size()), bgra.data()), "Writing the PNG pixels");
|
||||
check(frame->Commit(), "Finishing the PNG image");
|
||||
check(encoder->Commit(), "Finishing the PNG");
|
||||
}
|
||||
|
||||
Options parse(int argc, wchar_t **argv) {
|
||||
Options o;
|
||||
std::vector<fs::path> positional;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::wstring arg = argv[i];
|
||||
const auto value = [&]() -> std::wstring {
|
||||
if (i + 1 >= argc) throw std::runtime_error("missing a value after an option");
|
||||
return argv[++i];
|
||||
};
|
||||
if (arg == L"--size") o.size = std::stoi(value());
|
||||
else if (arg == L"--max-frames") o.max_frames = static_cast<std::size_t>(std::stoul(value()));
|
||||
else if (arg == L"--max-width") o.max_width = std::stoi(value());
|
||||
else if (arg == L"--name") o.name = fs::path(value()).string();
|
||||
else if (arg == L"--help" || arg == L"-h" || arg == L"/?") throw std::invalid_argument("");
|
||||
else positional.emplace_back(arg);
|
||||
}
|
||||
if (positional.empty() || positional.size() > 2) throw std::invalid_argument("");
|
||||
o.input = fs::absolute(positional[0]);
|
||||
o.output = positional.size() == 2 ? fs::absolute(positional[1])
|
||||
: o.input.parent_path() / (o.input.filename().wstring() + L"_pack");
|
||||
if (o.size < 8 || o.size > max_side) throw std::runtime_error("--size must be 8 to 256");
|
||||
if (o.max_width < max_side || o.max_width > max_texture) throw std::runtime_error("--max-width must be 256 to 8192");
|
||||
if (o.max_frames < 1 || o.max_frames > 512) throw std::runtime_error("--max-frames must be 1 to 512");
|
||||
return o;
|
||||
}
|
||||
|
||||
int run(const Options &o) {
|
||||
if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + o.input.string());
|
||||
std::vector<fs::path> files;
|
||||
for (const auto &entry : fs::directory_iterator(o.input)) {
|
||||
const auto extension = lowered(entry.path().extension().string());
|
||||
if (entry.is_regular_file() && (extension == ".png" || extension == ".gif" || extension == ".jpg" ||
|
||||
extension == ".jpeg" || extension == ".bmp" || extension == ".webp"))
|
||||
files.push_back(entry.path());
|
||||
}
|
||||
std::sort(files.begin(), files.end());
|
||||
if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + o.input.string());
|
||||
|
||||
std::vector<Emote> emotes;
|
||||
std::set<std::string> names, folded;
|
||||
std::size_t total{}, failed{};
|
||||
for (const auto &file : files) {
|
||||
try {
|
||||
auto name = emote_name(file);
|
||||
for (int n = 2; names.contains(name); ++n) {
|
||||
const auto suffix = "_" + std::to_string(n);
|
||||
name = emote_name(file).substr(0, max_name - suffix.size()) + suffix;
|
||||
}
|
||||
auto frames = decode(file);
|
||||
const bool animated = frames.size() > 1;
|
||||
for (auto &f : frames) f.image = scale(f.image, o.size);
|
||||
frames = tidy(std::move(frames), o.max_frames);
|
||||
if (!animated || frames.size() == 1) frames.front().time = 0;
|
||||
if (total + frames.size() > max_total_frames)
|
||||
throw std::runtime_error("the pack would pass ReSkate's " + std::to_string(max_total_frames) + " frames");
|
||||
total += frames.size();
|
||||
if (!folded.insert(lowered(name)).second)
|
||||
std::printf(" note: %s differs from another emote only in case; typing picks the first.\n", name.c_str());
|
||||
names.insert(name);
|
||||
std::printf(" :%s: %dx%d%s\n", name.c_str(), frames.front().image.width, frames.front().image.height,
|
||||
frames.size() > 1 ? (", " + std::to_string(frames.size()) + " frames").c_str() : "");
|
||||
emotes.push_back({name, file.filename().string(), std::move(frames)});
|
||||
} catch (const std::exception &e) {
|
||||
++failed;
|
||||
std::printf(" skipped %s: %s\n", file.filename().string().c_str(), e.what());
|
||||
}
|
||||
}
|
||||
if (emotes.empty()) throw std::runtime_error("no pictures could be read");
|
||||
|
||||
const int height = pack(emotes, o.max_width);
|
||||
if (height > max_texture)
|
||||
throw std::runtime_error("the atlas would be " + std::to_string(height) +
|
||||
" pixels tall (ReSkate takes 8192): use a smaller --size or --max-frames");
|
||||
std::vector<unsigned char> atlas(static_cast<std::size_t>(o.max_width) * height * 4, 0);
|
||||
auto emote_list = Json::object();
|
||||
for (const auto &e : emotes) {
|
||||
for (const auto &f : e.frames)
|
||||
for (int y = 0; y < f.image.height; ++y)
|
||||
std::copy_n(f.image.rgba.data() + static_cast<std::size_t>(y) * f.image.width * 4, f.image.width * 4,
|
||||
atlas.begin() + (static_cast<std::size_t>(f.y + y) * o.max_width + f.x) * 4);
|
||||
auto entry = Json::object();
|
||||
entry["size"] = Json::array({e.frames.front().image.width, e.frames.front().image.height});
|
||||
if (e.frames.size() == 1) {
|
||||
entry["pos"] = Json::array({e.frames.front().x, e.frames.front().y});
|
||||
} else {
|
||||
auto list = Json::array();
|
||||
for (const auto &f : e.frames) {
|
||||
auto frame = Json::object();
|
||||
frame["time"] = f.time;
|
||||
frame["pos"] = Json::array({f.x, f.y});
|
||||
list.push_back(std::move(frame));
|
||||
}
|
||||
entry["frames"] = std::move(list);
|
||||
}
|
||||
emote_list[e.name] = std::move(entry);
|
||||
}
|
||||
fs::create_directories(o.output);
|
||||
write_png(o.output / "emotes.png", o.max_width, height, atlas);
|
||||
auto document = Json::object();
|
||||
document["name"] = o.name;
|
||||
document["texture"] = "emotes.png";
|
||||
document["emotes"] = std::move(emote_list);
|
||||
{
|
||||
const auto text = document.dump(2);
|
||||
FILE *out{};
|
||||
if (_wfopen_s(&out, (o.output / "emotes.json").c_str(), L"wb") != 0 || !out)
|
||||
throw std::runtime_error("cannot write emotes.json");
|
||||
std::fwrite(text.data(), 1, text.size(), out);
|
||||
std::fclose(out);
|
||||
}
|
||||
std::printf("\n%zu emotes (%zu frames) in a %dx%d atlas%s.\nWrote %s\\emotes.json and emotes.png.\n"
|
||||
"Copy both into the repository's assets\\emotes and rebuild ReSkate.dll.\n",
|
||||
emotes.size(), total, o.max_width, height, failed ? (", " + std::to_string(failed) + " skipped").c_str() : "",
|
||||
o.output.string().c_str());
|
||||
return failed ? 2 : 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int wmain(int argc, wchar_t **argv) {
|
||||
std::setvbuf(stdout, nullptr, _IONBF, 0); // progress shows as it happens, even through a pipe
|
||||
const bool own_console = [] {
|
||||
DWORD processes[2];
|
||||
return GetConsoleProcessList(processes, 2) == 1; // started by double-click or drag-and-drop
|
||||
}();
|
||||
int code = 1;
|
||||
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) {
|
||||
std::printf("Windows Imaging could not start.\n");
|
||||
} else {
|
||||
try {
|
||||
code = run(parse(argc, argv));
|
||||
} catch (const std::invalid_argument &) {
|
||||
std::printf("ReSkateEmotePacker: builds ReSkate's chat emote pack from a folder of pictures and GIFs.\n\n"
|
||||
" ReSkateEmotePacker <folder> [output folder] [--size 56] [--max-frames 64]\n"
|
||||
" [--max-width 2048] [--name ReSkate]\n\n"
|
||||
"Each file becomes :FileName: (letters, digits and _). Animated GIFs keep their frames.\n"
|
||||
"Writes emotes.json and emotes.png (default: <folder>_pack next to the folder).\n");
|
||||
} catch (const std::exception &e) {
|
||||
std::printf("Error: %s\n", e.what());
|
||||
}
|
||||
CoUninitialize();
|
||||
}
|
||||
if (own_console) {
|
||||
std::printf("\nPress Enter to close.");
|
||||
std::getchar();
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "command_registry.h"
|
||||
#include <array>
|
||||
namespace dingosdk::console {
|
||||
std::vector<CompletionToken> completion_tokens(std::string_view input, int cursor) {
|
||||
const int size = static_cast<int>(input.size());
|
||||
const int limit = std::clamp(cursor, 0, size);
|
||||
std::vector<CompletionToken> tokens;
|
||||
int position = 0;
|
||||
const auto separator = [](char c) { return c == ' ' || c == '\t'; };
|
||||
while (position <= limit) {
|
||||
while (position < limit && separator(input[position]))
|
||||
++position;
|
||||
CompletionToken token;
|
||||
token.begin = position;
|
||||
char quote = 0;
|
||||
bool escaped = false;
|
||||
// Scan the whole token, but decode only its prefix before the cursor.
|
||||
// This matches execution's escapes and single/double/concatenated quotes.
|
||||
for (; position < size; ++position) {
|
||||
const char c = input[position];
|
||||
if (!escaped && !quote && separator(c))
|
||||
break;
|
||||
const bool before_cursor = position < limit;
|
||||
if (escaped) {
|
||||
if (before_cursor)
|
||||
token.text += c;
|
||||
escaped = false;
|
||||
} else if (c == '\\')
|
||||
escaped = true;
|
||||
else if (quote) {
|
||||
if (c == quote)
|
||||
quote = 0;
|
||||
else if (before_cursor)
|
||||
token.text += c;
|
||||
} else if (c == '\'' || c == '"') {
|
||||
quote = c;
|
||||
token.quoted = true;
|
||||
} else if (before_cursor)
|
||||
token.text += c;
|
||||
}
|
||||
token.end = position;
|
||||
tokens.push_back(std::move(token));
|
||||
if (position >= limit)
|
||||
break;
|
||||
++position;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
std::string lower(std::string_view text) {
|
||||
std::string result(text);
|
||||
for (auto &c : result)
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = static_cast<char>(c + ('a' - 'A'));
|
||||
return result;
|
||||
}
|
||||
bool equal(std::string_view a, std::string_view b) {
|
||||
return a.size() == b.size() && ascii_case_insensitive_starts_with(a, b);
|
||||
}
|
||||
const char *group_name(Group group) noexcept {
|
||||
constexpr std::array names{"Console", "Movement", "Gameplay", "World",
|
||||
"Graphics", "Progression", "Objects", "Engine"};
|
||||
const auto i = static_cast<std::size_t>(group);
|
||||
return i < names.size() ? names[i] : "Console";
|
||||
}
|
||||
std::string value_text(const Value &value) {
|
||||
return std::visit(
|
||||
[](const auto &v) -> std::string {
|
||||
using T = std::decay_t<decltype(v)>;
|
||||
if constexpr (std::is_same_v<T, std::string>)
|
||||
return v;
|
||||
else if constexpr (std::is_same_v<T, bool>)
|
||||
return v ? "1" : "0";
|
||||
else {
|
||||
std::array<char, 64> text{};
|
||||
const auto result = std::to_chars(text.data(), text.data() + text.size(), v);
|
||||
return result.ec == std::errc{} ? std::string(text.data(), result.ptr) : "?";
|
||||
}
|
||||
},
|
||||
value);
|
||||
}
|
||||
std::optional<Value> parse_value(Type type, std::string_view text) {
|
||||
if (type == Type::text)
|
||||
return std::string(text);
|
||||
if (type == Type::boolean) {
|
||||
if (text == "1" || equal(text, "true") || equal(text, "on"))
|
||||
return true;
|
||||
if (text == "0" || equal(text, "false") || equal(text, "off"))
|
||||
return false;
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto parse = [&]<class T>() -> std::optional<Value> {
|
||||
T value{};
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (result.ec != std::errc{} || result.ptr != text.data() + text.size())
|
||||
return std::nullopt;
|
||||
if constexpr (std::is_floating_point_v<T>)
|
||||
if (!std::isfinite(value))
|
||||
return std::nullopt;
|
||||
return value;
|
||||
};
|
||||
switch (type) {
|
||||
case Type::integer:
|
||||
return parse.operator()<std::int64_t>();
|
||||
case Type::unsigned_integer:
|
||||
return parse.operator()<std::uint64_t>();
|
||||
case Type::number:
|
||||
return parse.operator()<double>();
|
||||
default:
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::console
|
||||
@@ -0,0 +1,354 @@
|
||||
#pragma once
|
||||
|
||||
#include "console_core.h"
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dingosdk::console {
|
||||
enum class Group {
|
||||
console,
|
||||
movement,
|
||||
gameplay,
|
||||
world,
|
||||
graphics,
|
||||
progression,
|
||||
objects,
|
||||
engine
|
||||
};
|
||||
enum class Kind {
|
||||
action,
|
||||
variable
|
||||
};
|
||||
enum class Execution {
|
||||
local,
|
||||
game_thread
|
||||
};
|
||||
enum class Type {
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
unsigned_integer,
|
||||
number
|
||||
};
|
||||
using Value = std::variant<std::string, bool, std::int64_t, std::uint64_t, double>;
|
||||
using Values = std::vector<Value>;
|
||||
struct Output {
|
||||
std::function<void(const std::string &)> write;
|
||||
std::function<void()> clear;
|
||||
std::function<void()> history;
|
||||
void operator()(const std::string &text) const {
|
||||
if (write)
|
||||
write(text);
|
||||
}
|
||||
};
|
||||
struct State {
|
||||
bool available = true;
|
||||
std::optional<std::string> value;
|
||||
std::string reason;
|
||||
std::string detail;
|
||||
bool overridden = false;
|
||||
};
|
||||
std::string lower(std::string_view text);
|
||||
bool equal(std::string_view a, std::string_view b);
|
||||
const char *group_name(Group group) noexcept;
|
||||
std::string value_text(const Value &value);
|
||||
std::optional<Value> parse_value(Type type, std::string_view text);
|
||||
|
||||
struct CompletionToken {
|
||||
std::string text;
|
||||
int begin = 0, end = 0;
|
||||
bool quoted = false;
|
||||
};
|
||||
std::vector<CompletionToken> completion_tokens(std::string_view input, int cursor);
|
||||
|
||||
struct Suggestion {
|
||||
std::string text, name, description, usage;
|
||||
Group group = Group::console;
|
||||
Kind kind = Kind::action;
|
||||
State state;
|
||||
bool argument = false;
|
||||
};
|
||||
|
||||
template <class Context> class Registry {
|
||||
public:
|
||||
struct Argument {
|
||||
std::string name;
|
||||
Type type = Type::text;
|
||||
bool optional = false, rest = false;
|
||||
std::vector<std::string> choices;
|
||||
std::optional<double> minimum, maximum;
|
||||
std::function<std::vector<std::string>(const Context &, std::span<const std::string>)> complete;
|
||||
};
|
||||
struct Entry {
|
||||
std::string name, description;
|
||||
Group group = Group::console;
|
||||
Kind kind = Kind::action;
|
||||
Execution execution = Execution::game_thread;
|
||||
bool echo_input = true;
|
||||
std::vector<Argument> arguments;
|
||||
std::vector<std::string> aliases;
|
||||
std::function<State(const Context &)> inspect;
|
||||
std::function<void(const Context &, const Values &, const Output &)> run;
|
||||
std::function<void(const Context &, const Output &)> reset;
|
||||
};
|
||||
Registry() = default;
|
||||
Registry(const Registry &) = delete;
|
||||
Registry &operator=(const Registry &) = delete;
|
||||
|
||||
void add(Entry entry) {
|
||||
const auto parsed = parse_console_command(entry.name);
|
||||
if (!parsed || parsed.arguments.empty())
|
||||
throw std::logic_error("Invalid command name");
|
||||
bool optional = false;
|
||||
for (std::size_t i = 0; i < entry.arguments.size(); ++i) {
|
||||
const auto &arg = entry.arguments[i];
|
||||
if (arg.name.empty() || (optional && !arg.optional) ||
|
||||
(arg.rest && (i + 1 != entry.arguments.size() || arg.type != Type::text)))
|
||||
throw std::logic_error("Invalid command argument schema: " + entry.name);
|
||||
optional = arg.optional;
|
||||
}
|
||||
if (entry.kind == Kind::variable && (entry.arguments.size() != 1 || !entry.inspect))
|
||||
throw std::logic_error("Variables require one typed argument and a state reader");
|
||||
std::vector<std::string> names{entry.name};
|
||||
names.insert(names.end(), entry.aliases.begin(), entry.aliases.end());
|
||||
std::map<std::string, bool> unique;
|
||||
for (const auto &name : names) {
|
||||
const auto key = lower(name);
|
||||
if (!parse_console_command(name) || name.empty() || lookup_.contains(key) ||
|
||||
!unique.emplace(key, true).second)
|
||||
throw std::logic_error("Duplicate/invalid command: " + name);
|
||||
}
|
||||
const auto index = entries_.size();
|
||||
entries_.push_back(std::move(entry));
|
||||
for (const auto &name : names)
|
||||
lookup_.emplace(lower(name), index);
|
||||
}
|
||||
const std::vector<Entry> &entries() const noexcept {
|
||||
return entries_;
|
||||
}
|
||||
const Entry *find(std::string_view name) const {
|
||||
const auto it = lookup_.find(lower(name));
|
||||
return it == lookup_.end() ? nullptr : &entries_[it->second];
|
||||
}
|
||||
static std::string usage(const Entry &entry) {
|
||||
std::string result = entry.name;
|
||||
for (const auto &arg : entry.arguments) {
|
||||
const bool optional = arg.optional || entry.kind == Kind::variable;
|
||||
result += optional ? " [" : " <";
|
||||
result += arg.name;
|
||||
if (arg.rest)
|
||||
result += "...";
|
||||
result += optional ? "]" : ">";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
State state(const Entry &entry, const Context &context) const {
|
||||
return entry.inspect ? entry.inspect(context) : State{};
|
||||
}
|
||||
std::string describe(const Entry &entry, const Context &context) const {
|
||||
const auto current = state(entry, context);
|
||||
std::string result = entry.name;
|
||||
if (current.value)
|
||||
result += " [" + *current.value + "]";
|
||||
if (!entry.description.empty())
|
||||
result += " - " + entry.description;
|
||||
if (!current.available)
|
||||
result += " | unavailable: " + current.reason;
|
||||
if (!current.detail.empty())
|
||||
result += " | " + current.detail;
|
||||
return result;
|
||||
}
|
||||
bool echoes(std::span<const std::string> words) const {
|
||||
const auto [entry, consumed] = resolve(words);
|
||||
return !entry || entry->echo_input || consumed != words.size();
|
||||
}
|
||||
bool local(std::span<const std::string> words, const Context &context) const {
|
||||
const auto [entry, consumed] = resolve(words);
|
||||
return !entry || entry->execution == Execution::local ||
|
||||
(entry->kind == Kind::variable && consumed == words.size()) || !state(*entry, context).available;
|
||||
}
|
||||
bool execute(std::string_view text, const Context &context, const Output &output) const {
|
||||
const auto parsed = parse_console_command(text);
|
||||
if (!parsed || parsed.arguments.empty()) {
|
||||
output("error: Invalid command text; check quoting and length.");
|
||||
return false;
|
||||
}
|
||||
return execute(parsed.arguments, context, output);
|
||||
}
|
||||
bool execute(std::span<const std::string> words, const Context &context, const Output &output) const {
|
||||
const auto [entry, consumed] = resolve(words);
|
||||
if (!entry) {
|
||||
output("error: Unknown command. Use help or Tab completion.");
|
||||
return false;
|
||||
}
|
||||
const auto current = state(*entry, context);
|
||||
if (entry->kind == Kind::variable && consumed == words.size()) {
|
||||
output(describe(*entry, context));
|
||||
return true;
|
||||
}
|
||||
if (!current.available) {
|
||||
output("error: " + entry->name + " is unavailable: " + current.reason);
|
||||
return false;
|
||||
}
|
||||
const auto args = words.subspan(consumed);
|
||||
Values values;
|
||||
std::size_t index = 0;
|
||||
for (const auto &arg : entry->arguments) {
|
||||
if (index == args.size()) {
|
||||
if (arg.optional)
|
||||
break;
|
||||
output("error: Missing " + arg.name + ". Usage: " + usage(*entry));
|
||||
return false;
|
||||
}
|
||||
std::string token = args[index++];
|
||||
if (arg.rest)
|
||||
while (index < args.size()) {
|
||||
token += ' ';
|
||||
token += args[index++];
|
||||
}
|
||||
auto value = parse_value(arg.type, token);
|
||||
if (!value) {
|
||||
output("error: Invalid " + arg.name + ": '" + token + "'. Usage: " + usage(*entry));
|
||||
return false;
|
||||
}
|
||||
if (!arg.choices.empty()) {
|
||||
const auto match = std::find_if(arg.choices.begin(), arg.choices.end(), [&](const auto &choice) {
|
||||
if (arg.type == Type::text)
|
||||
return equal(choice, token);
|
||||
const auto parsed_choice = parse_value(arg.type, choice);
|
||||
return parsed_choice && *parsed_choice == *value;
|
||||
});
|
||||
if (match == arg.choices.end()) {
|
||||
std::string choices;
|
||||
for (const auto &choice : arg.choices) {
|
||||
if (!choices.empty())
|
||||
choices += ", ";
|
||||
choices += choice;
|
||||
}
|
||||
output("error: " + arg.name + " must be one of: " + choices);
|
||||
return false;
|
||||
}
|
||||
value = parse_value(arg.type, *match);
|
||||
}
|
||||
if (arg.minimum || arg.maximum) {
|
||||
const auto numeric = std::visit(
|
||||
[](const auto &v) -> double {
|
||||
using T = std::decay_t<decltype(v)>;
|
||||
if constexpr (std::is_arithmetic_v<T>)
|
||||
return static_cast<double>(v);
|
||||
else
|
||||
return 0;
|
||||
},
|
||||
*value);
|
||||
if ((arg.minimum && numeric < *arg.minimum) || (arg.maximum && numeric > *arg.maximum)) {
|
||||
output("error: " + arg.name + " is outside the allowed range. Use help " + entry->name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
values.push_back(std::move(*value));
|
||||
}
|
||||
if (index != args.size()) {
|
||||
output("error: Too many arguments. Usage: " + usage(*entry));
|
||||
return false;
|
||||
}
|
||||
if (!entry->run) {
|
||||
output("error: " + entry->name + " is read-only.");
|
||||
return false;
|
||||
}
|
||||
entry->run(context, values, output);
|
||||
return true;
|
||||
}
|
||||
// words includes the active, possibly empty token; completion only reads Context.
|
||||
std::vector<Suggestion> complete(std::span<const std::string> words, const Context &context) const {
|
||||
std::vector<Suggestion> result;
|
||||
std::set<std::string> emitted;
|
||||
if (words.empty())
|
||||
return result;
|
||||
const auto prefix = words.back();
|
||||
const auto prior = words.first(words.size() - 1);
|
||||
auto add = [&](std::string text, const Entry &entry, bool argument) {
|
||||
if (!ascii_case_insensitive_starts_with(text, prefix))
|
||||
return;
|
||||
if (!emitted.insert(lower(text)).second)
|
||||
return;
|
||||
result.push_back({std::move(text), entry.name, entry.description, usage(entry), entry.group, entry.kind,
|
||||
state(entry, context), argument});
|
||||
};
|
||||
for (const auto &item : entries_) {
|
||||
std::vector<std::string> routes{item.name};
|
||||
// Multiword aliases also supply the menu command routes.
|
||||
for (const auto &alias : item.aliases)
|
||||
if (alias.find(' ') != std::string::npos)
|
||||
routes.push_back(alias);
|
||||
for (const auto &route : routes) {
|
||||
const auto path = parse_console_command(route).arguments;
|
||||
if (prior.size() >= path.size())
|
||||
continue;
|
||||
bool matches = true;
|
||||
for (std::size_t i = 0; i < prior.size(); ++i)
|
||||
matches = matches && equal(prior[i], path[i]);
|
||||
if (!matches)
|
||||
continue;
|
||||
const auto before = result.size();
|
||||
add(path[prior.size()], item, false);
|
||||
if (result.size() == before)
|
||||
continue;
|
||||
auto &suggestion = result.back();
|
||||
if (prior.size() + 1 < path.size()) {
|
||||
suggestion.name = suggestion.text;
|
||||
suggestion.description = "Browse " + suggestion.text + " commands";
|
||||
suggestion.usage = route;
|
||||
suggestion.kind = Kind::action;
|
||||
suggestion.state = {};
|
||||
} else
|
||||
suggestion.name = route;
|
||||
}
|
||||
}
|
||||
const auto [entry, consumed] = resolve(prior);
|
||||
if (entry) {
|
||||
const auto arg_index = prior.size() - consumed;
|
||||
if (arg_index < entry->arguments.size()) {
|
||||
const auto &arg = entry->arguments[arg_index];
|
||||
auto candidates = arg.complete ? arg.complete(context, prior.subspan(consumed)) : arg.choices;
|
||||
if (candidates.empty() && arg.type == Type::boolean)
|
||||
candidates = {"0", "1"};
|
||||
for (const auto &candidate : candidates)
|
||||
add(candidate, *entry, true);
|
||||
}
|
||||
}
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const auto &a, const auto &b) { return lower(a.text) < lower(b.text); });
|
||||
if (result.size() > console_max_autocomplete_matches)
|
||||
result.resize(console_max_autocomplete_matches);
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
std::pair<const Entry *, std::size_t> resolve(std::span<const std::string> words) const {
|
||||
const Entry *entry = nullptr;
|
||||
std::size_t consumed = 0;
|
||||
std::string path;
|
||||
for (std::size_t i = 0; i < words.size(); ++i) {
|
||||
if (i)
|
||||
path += ' ';
|
||||
path += words[i];
|
||||
if (const auto *found = find(path)) {
|
||||
entry = found;
|
||||
consumed = i + 1;
|
||||
}
|
||||
}
|
||||
return {entry, consumed};
|
||||
}
|
||||
std::vector<Entry> entries_;
|
||||
std::map<std::string, std::size_t> lookup_;
|
||||
};
|
||||
} // namespace dingosdk::console
|
||||
@@ -0,0 +1,95 @@
|
||||
#include "console_activity.h"
|
||||
#include <format>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
enum class Phase { assets, profile, disconnected, unloading, loading, active, connecting,
|
||||
sublevel_loading, sublevel_active, shutdown, idle };
|
||||
Phase phase(unsigned state) {
|
||||
switch (state) {
|
||||
case 0: return Phase::assets;
|
||||
case 1: return Phase::profile;
|
||||
case 2: return Phase::disconnected;
|
||||
case 3: case 14: case 22: return Phase::unloading;
|
||||
case 4: case 5: case 6: case 7: case 8: case 9: case 10: case 11: case 12: return Phase::loading;
|
||||
case 13: return Phase::active;
|
||||
case 15: case 23: return Phase::connecting;
|
||||
case 16: case 17: case 18: case 19: case 20: return Phase::sublevel_loading;
|
||||
case 21: return Phase::sublevel_active;
|
||||
case 24: case 25: return Phase::shutdown;
|
||||
default: return Phase::idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
void ConsoleActivityFeed::update(const ConsoleActivity& a, Emit sink, std::uint64_t now_ms) {
|
||||
const ConsoleActivity empty;
|
||||
const auto& before = previous_ ? *previous_ : empty;
|
||||
const auto emit = [&](ConsoleSource source, std::string text,
|
||||
ConsoleSeverity severity = ConsoleSeverity::info) {
|
||||
if (sink) sink(source, text, severity);
|
||||
};
|
||||
const auto current = phase(a.state);
|
||||
const bool loading = current == Phase::loading || current == Phase::sublevel_loading;
|
||||
const bool sublevel = current == Phase::sublevel_loading || current == Phase::sublevel_active;
|
||||
const auto& destination = sublevel ? a.sublevel : a.level;
|
||||
const bool changed = !previous_ || current != phase(before.state) || a.level != before.level ||
|
||||
(sublevel && a.sublevel != before.sublevel);
|
||||
if (changed && !a.native_loading_logging) {
|
||||
const auto suffix = destination.empty() ? "." : ": " + destination;
|
||||
if (loading) {
|
||||
if (!load_started_ || current != phase(before.state)) load_started_ = now_ms;
|
||||
waiting_reported_ = false;
|
||||
preparation_reported_ = false;
|
||||
emit(ConsoleSource::level, std::string(sublevel ? "Loading sublevel" : "Loading world") + suffix);
|
||||
} else if (current == Phase::active || current == Phase::sublevel_active) {
|
||||
const auto duration = load_started_ ? std::format(" (load took {:.1f}s)",
|
||||
static_cast<double>(now_ms - *load_started_) / 1000.0) : "";
|
||||
emit(ConsoleSource::level, std::string(sublevel ? "Sublevel active" : "World active") + suffix + duration,
|
||||
ConsoleSeverity::success);
|
||||
load_started_.reset();
|
||||
} else {
|
||||
load_started_.reset();
|
||||
switch (current) {
|
||||
case Phase::assets: emit(ConsoleSource::assets, "Loading shared game assets."); break;
|
||||
case Phase::profile: emit(ConsoleSource::profile, "Applying profile options."); break;
|
||||
case Phase::disconnected: emit(ConsoleSource::network, "Game reported a lost connection.", ConsoleSeverity::warning); break;
|
||||
case Phase::unloading: emit(ConsoleSource::level, "Unloading world content" + suffix); break;
|
||||
case Phase::connecting: emit(ConsoleSource::level, "Connecting to local world" + suffix); break;
|
||||
case Phase::shutdown: emit(ConsoleSource::runtime, "Game is shutting down."); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!a.native_loading_logging && loading && load_started_ && !waiting_reported_ && now_ms - *load_started_ >= 30000) {
|
||||
emit(ConsoleSource::level, "Still loading after 30 seconds: " + a.state_name +
|
||||
(destination.empty() ? "." : " - " + destination), ConsoleSeverity::warning);
|
||||
waiting_reported_ = true;
|
||||
}
|
||||
if (!a.sublevel.empty() && a.sublevel_active && a.client_sublevel_present &&
|
||||
current != Phase::sublevel_active && (a.sublevel != before.sublevel ||
|
||||
!before.sublevel_active || !before.client_sublevel_present))
|
||||
emit(ConsoleSource::level, "Sublevel content ready: " + a.sublevel, ConsoleSeverity::success);
|
||||
if (a.players_available && (!before.players_available || a.players != before.players) &&
|
||||
(a.players || before.players)) {
|
||||
emit(ConsoleSource::player, a.players ? "Local player joined (" + std::to_string(a.players) + ")."
|
||||
: "Local player left.");
|
||||
}
|
||||
if (a.players_available && a.controllables != before.controllables) {
|
||||
emit(ConsoleSource::player, a.controllables ? "Controllable entity assigned to local player."
|
||||
: "Local player released its controllable entity.");
|
||||
}
|
||||
if (a.setup_attempts > before.setup_attempts && !preparation_reported_) {
|
||||
emit(ConsoleSource::skater, "Skater initialization started.");
|
||||
preparation_reported_ = true;
|
||||
}
|
||||
if (a.skater != before.skater) {
|
||||
if (a.skater) emit(ConsoleSource::skater,
|
||||
std::format("Local skater entity available at ({:.1f}, {:.1f}, {:.1f}).",
|
||||
a.position[0], a.position[1], a.position[2]), ConsoleSeverity::success);
|
||||
else emit(ConsoleSource::skater, "Local skater entity no longer available.");
|
||||
}
|
||||
if (!a.flow.empty() && a.flow != before.flow)
|
||||
emit(ConsoleSource::flow, a.flow, a.flow == "InGame" ? ConsoleSeverity::success : ConsoleSeverity::info);
|
||||
previous_ = a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "console_core.h"
|
||||
#include <array>
|
||||
|
||||
namespace dingosdk {
|
||||
// Values sampled by the existing game-thread observers; no counters that change
|
||||
// every frame belong in the console. Positions are logged only on entity arrival.
|
||||
struct ConsoleActivity {
|
||||
unsigned state{26};
|
||||
bool native_loading_logging{}; // Detailed native stages replace coarse level messages when available.
|
||||
std::string state_name, level, sublevel, flow;
|
||||
bool sublevel_active{}, client_sublevel_present{}, players_available{};
|
||||
unsigned players{}, controllables{};
|
||||
std::uint64_t setup_attempts{};
|
||||
std::uintptr_t skater{};
|
||||
std::array<float, 3> position{};
|
||||
};
|
||||
|
||||
class ConsoleActivityFeed {
|
||||
public:
|
||||
using Emit = void (*)(ConsoleSource, std::string_view, ConsoleSeverity);
|
||||
void update(const ConsoleActivity& activity, Emit emit, std::uint64_t now_ms = console_timestamp_ms());
|
||||
private:
|
||||
std::optional<ConsoleActivity> previous_;
|
||||
bool preparation_reported_{}, waiting_reported_{};
|
||||
std::optional<std::uint64_t> load_started_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
#include "console_core.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
|
||||
bool is_separator(char value) noexcept {
|
||||
return value == ' ' || value == '\t';
|
||||
}
|
||||
|
||||
bool is_allowed_input_character(unsigned char value) noexcept {
|
||||
return value == static_cast<unsigned char>('\t') ||
|
||||
(value >= static_cast<unsigned char>(' ') &&
|
||||
value <= static_cast<unsigned char>('~'));
|
||||
}
|
||||
|
||||
char ascii_lower(char value) noexcept {
|
||||
if (value >= 'A' && value <= 'Z') {
|
||||
return static_cast<char>(value + ('a' - 'A'));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CommandParseResult parse_console_command(std::string_view input) {
|
||||
CommandParseResult result;
|
||||
if (input.size() > console_max_command_bytes) {
|
||||
result.error = CommandParseError::input_too_long;
|
||||
result.error_offset = console_max_command_bytes;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < input.size(); ++index) {
|
||||
if (!is_allowed_input_character(static_cast<unsigned char>(input[index]))) {
|
||||
result.error = CommandParseError::invalid_character;
|
||||
result.error_offset = index;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
std::string argument;
|
||||
argument.reserve(input.size());
|
||||
bool argument_started = false;
|
||||
bool escaped = false;
|
||||
char quote = '\0';
|
||||
|
||||
const auto finish_argument = [&]() -> bool {
|
||||
if (!argument_started) return true;
|
||||
if (result.arguments.size() == console_max_arguments) {
|
||||
result.error = CommandParseError::too_many_arguments;
|
||||
return false;
|
||||
}
|
||||
result.arguments.push_back(argument);
|
||||
argument.clear();
|
||||
argument_started = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (std::size_t index = 0; index < input.size(); ++index) {
|
||||
const char value = input[index];
|
||||
if (escaped) {
|
||||
argument.push_back(value);
|
||||
argument_started = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (value == '\\') {
|
||||
escaped = true;
|
||||
argument_started = true;
|
||||
continue;
|
||||
}
|
||||
if (quote != '\0') {
|
||||
if (value == quote) {
|
||||
quote = '\0';
|
||||
} else {
|
||||
argument.push_back(value);
|
||||
}
|
||||
argument_started = true;
|
||||
continue;
|
||||
}
|
||||
if (value == '\'' || value == '"') {
|
||||
quote = value;
|
||||
argument_started = true;
|
||||
continue;
|
||||
}
|
||||
if (is_separator(value)) {
|
||||
if (!finish_argument()) {
|
||||
result.error_offset = index;
|
||||
result.arguments.clear();
|
||||
return result;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
argument.push_back(value);
|
||||
argument_started = true;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
result.error = CommandParseError::dangling_escape;
|
||||
result.error_offset = input.empty() ? 0 : input.size() - 1;
|
||||
result.arguments.clear();
|
||||
return result;
|
||||
}
|
||||
if (quote != '\0') {
|
||||
result.error = CommandParseError::unterminated_quote;
|
||||
result.error_offset = input.size();
|
||||
result.arguments.clear();
|
||||
return result;
|
||||
}
|
||||
if (!finish_argument()) {
|
||||
result.error_offset = input.size();
|
||||
result.arguments.clear();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ascii_case_insensitive_starts_with(std::string_view value,
|
||||
std::string_view prefix) noexcept {
|
||||
if (prefix.size() > value.size()) return false;
|
||||
for (std::size_t index = 0; index < prefix.size(); ++index) {
|
||||
if (ascii_lower(value[index]) != ascii_lower(prefix[index])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ConsoleLogBuffer::ConsoleLogBuffer(ConsoleLogLimits limits)
|
||||
: limits_(limits) {
|
||||
if (limits_.max_lines == 0 || limits_.max_bytes == 0 ||
|
||||
limits_.max_line_bytes == 0 || limits_.max_line_bytes > limits_.max_bytes) {
|
||||
throw std::invalid_argument("Invalid console log limits");
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t console_timestamp_ms() noexcept {
|
||||
static const auto start = std::chrono::steady_clock::now();
|
||||
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start).count());
|
||||
}
|
||||
|
||||
const char* console_source_name(ConsoleSource source) noexcept {
|
||||
return logging::name(source).data();
|
||||
}
|
||||
|
||||
std::string format_console_line(const ConsoleLogLine& line, bool timestamp) {
|
||||
std::ostringstream output;
|
||||
if (timestamp) output << '[' << std::fixed << std::setprecision(3)
|
||||
<< std::setw(9) << static_cast<double>(line.elapsed_ms) / 1000.0 << "] ";
|
||||
output << logging::name(line.context) << ": [" << console_source_name(line.source)
|
||||
<< "] [" << logging::name(line.severity) << "] ";
|
||||
output << line.text;
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::uint64_t ConsoleLogBuffer::append(std::string_view text,
|
||||
ConsoleSeverity severity, ConsoleSource source, logging::Context context) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (text.empty()) return latest_sequence_;
|
||||
const auto elapsed_ms = console_timestamp_ms();
|
||||
|
||||
std::size_t start = 0;
|
||||
while (start < text.size()) {
|
||||
const auto newline = text.find('\n', start);
|
||||
if (newline == std::string_view::npos) {
|
||||
append_line_locked(text.substr(start), elapsed_ms, severity, source, context);
|
||||
break;
|
||||
}
|
||||
auto line = text.substr(start, newline - start);
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
append_line_locked(line, elapsed_ms, severity, source, context);
|
||||
start = newline + 1;
|
||||
}
|
||||
return latest_sequence_;
|
||||
}
|
||||
|
||||
std::vector<ConsoleLogLine> ConsoleLogBuffer::snapshot_after(
|
||||
std::uint64_t after_sequence) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
std::vector<ConsoleLogLine> snapshot;
|
||||
for (const auto& line : lines_) {
|
||||
if (line.sequence > after_sequence) snapshot.push_back(line);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
std::uint64_t ConsoleLogBuffer::latest_sequence() const noexcept {
|
||||
std::lock_guard lock(mutex_);
|
||||
return latest_sequence_;
|
||||
}
|
||||
|
||||
void ConsoleLogBuffer::append_line_locked(std::string_view text, std::uint64_t elapsed_ms,
|
||||
ConsoleSeverity severity, ConsoleSource source, logging::Context context) {
|
||||
if (latest_sequence_ == (std::numeric_limits<std::uint64_t>::max)()) {
|
||||
throw std::overflow_error("Console log sequence exhausted");
|
||||
}
|
||||
const auto bounded = text.substr(0, limits_.max_line_bytes);
|
||||
lines_.push_back({++latest_sequence_, std::string(bounded), elapsed_ms, severity, source, context});
|
||||
stored_bytes_ += bounded.size();
|
||||
enforce_limits_locked();
|
||||
}
|
||||
|
||||
void ConsoleLogBuffer::enforce_limits_locked() {
|
||||
while (lines_.size() > limits_.max_lines || stored_bytes_ > limits_.max_bytes) {
|
||||
stored_bytes_ -= lines_.front().text.size();
|
||||
lines_.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include "console_entry.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
|
||||
inline constexpr std::size_t console_max_command_bytes = 4096;
|
||||
inline constexpr std::size_t console_max_arguments = 64;
|
||||
inline constexpr std::size_t console_max_autocomplete_matches = 256;
|
||||
|
||||
enum class CommandParseError {
|
||||
none,
|
||||
input_too_long,
|
||||
invalid_character,
|
||||
too_many_arguments,
|
||||
unterminated_quote,
|
||||
dangling_escape,
|
||||
};
|
||||
|
||||
struct CommandParseResult {
|
||||
std::vector<std::string> arguments;
|
||||
CommandParseError error{CommandParseError::none};
|
||||
std::size_t error_offset{};
|
||||
|
||||
explicit operator bool() const noexcept { return error == CommandParseError::none; }
|
||||
};
|
||||
|
||||
// Parses printable ASCII command text. Space and horizontal tab separate
|
||||
// arguments. Single and double quotes preserve separators, and a backslash
|
||||
// escapes the following printable character both inside and outside quotes.
|
||||
CommandParseResult parse_console_command(std::string_view input);
|
||||
|
||||
bool ascii_case_insensitive_starts_with(std::string_view value,
|
||||
std::string_view prefix) noexcept;
|
||||
|
||||
struct ConsoleLogLimits {
|
||||
std::size_t max_lines{2048};
|
||||
std::size_t max_bytes{1024 * 1024};
|
||||
std::size_t max_line_bytes{4096};
|
||||
};
|
||||
|
||||
class ConsoleLogBuffer {
|
||||
public:
|
||||
explicit ConsoleLogBuffer(ConsoleLogLimits limits = {});
|
||||
ConsoleLogBuffer(const ConsoleLogBuffer&) = delete;
|
||||
ConsoleLogBuffer& operator=(const ConsoleLogBuffer&) = delete;
|
||||
|
||||
// Splits LF and CRLF input into logical lines. A terminal newline does not
|
||||
// create an extra line, while explicit adjacent newlines preserve blanks.
|
||||
// Returns the latest sequence after the append, or the prior sequence for
|
||||
// empty input.
|
||||
std::uint64_t append(std::string_view text, ConsoleSeverity severity,
|
||||
ConsoleSource source, logging::Context context);
|
||||
|
||||
// Returns currently retained lines whose sequence is strictly greater than
|
||||
// after_sequence.
|
||||
std::vector<ConsoleLogLine> snapshot_after(std::uint64_t after_sequence) const;
|
||||
|
||||
std::uint64_t latest_sequence() const noexcept;
|
||||
|
||||
private:
|
||||
void append_line_locked(std::string_view text, std::uint64_t elapsed_ms,
|
||||
ConsoleSeverity severity, ConsoleSource source, logging::Context context);
|
||||
void enforce_limits_locked();
|
||||
|
||||
const ConsoleLogLimits limits_;
|
||||
mutable std::mutex mutex_;
|
||||
std::deque<ConsoleLogLine> lines_;
|
||||
std::size_t stored_bytes_{};
|
||||
std::uint64_t latest_sequence_{};
|
||||
};
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "Engine/Core/Log/types.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk {
|
||||
using ConsoleSeverity = logging::Level;
|
||||
using ConsoleSource = logging::Channel;
|
||||
|
||||
struct ConsoleLogLine {
|
||||
std::uint64_t sequence{};
|
||||
std::string text;
|
||||
std::uint64_t elapsed_ms{};
|
||||
ConsoleSeverity severity{ConsoleSeverity::info};
|
||||
ConsoleSource source{ConsoleSource::runtime};
|
||||
logging::Context context{logging::default_context(source)};
|
||||
};
|
||||
|
||||
std::uint64_t console_timestamp_ms() noexcept;
|
||||
const char* console_source_name(ConsoleSource source) noexcept;
|
||||
std::string format_console_line(const ConsoleLogLine& line, bool timestamp = true);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
#include <Windows.h>
|
||||
#include <DbgHelp.h>
|
||||
#include "Engine/Core/Debug/backtrace.h"
|
||||
#include "Engine/Core/Debug/native_dump.h"
|
||||
#include "Engine/Core/Debug/upload.h"
|
||||
#include "Engine/Core/Debug/multipart.h"
|
||||
#include "Engine/Core/Debug/protocol.h"
|
||||
#include "backtrace_config.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <fstream>
|
||||
#include <format>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::backtrace;
|
||||
namespace {
|
||||
constexpr DWORD test_exception = 0xe0424242;
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
LONG WINAPI finish_child(EXCEPTION_POINTERS*) { ExitProcess(42); }
|
||||
std::filesystem::path native_fixture_directory;
|
||||
LONG WINAPI native_child_handler(EXCEPTION_POINTERS* pointers) {
|
||||
const auto path = native_fixture_directory / L"game-crash.mdmp";
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
MINIDUMP_EXCEPTION_INFORMATION exception{GetCurrentThreadId(), pointers, FALSE};
|
||||
if (file.value == INVALID_HANDLE_VALUE || !MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
|
||||
file.value, MiniDumpNormal, &exception, nullptr, nullptr)) ExitProcess(43);
|
||||
CloseHandle(file.release());
|
||||
CopyFileW(path.c_str(), (native_fixture_directory / L"duplicate.dmp").c_str(), TRUE);
|
||||
ExitProcess(42);
|
||||
}
|
||||
std::string read(const std::filesystem::path& path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
void endpoint_checks() {
|
||||
Endpoint endpoint;
|
||||
check(parse_endpoint(L"https://submit.backtrace.io/team/token/minidump", endpoint), "Hosted endpoint rejected");
|
||||
check(endpoint.host == L"submit.backtrace.io" && endpoint.port == 443 && endpoint.resource == L"/team/token/minidump", "Hosted URL components incorrect");
|
||||
check(parse_endpoint(L"https://team.sp.backtrace.io:6098/post?format=minidump&token=test", endpoint), "Listener endpoint rejected");
|
||||
check(endpoint.port == 6098 && endpoint.resource == L"/post?format=minidump&token=test", "Listener query lost");
|
||||
for (const auto* value : {L"", L"http://example.com/post", L"https://user:password@example.com/post",
|
||||
L"https://example.com/post#fragment", L"https://example.com/post\r\nheader", L"https:///post", L"https://example.com"})
|
||||
check(!parse_endpoint(value, endpoint), "Unsafe endpoint accepted");
|
||||
std::string id;
|
||||
check(accepted_response(200, R"({"response":"ok","_rxid":"1234-abcd"})", id) && id == "1234-abcd", "Valid receipt rejected");
|
||||
for (const auto* response : {R"({"response":"error","_rxid":"1234"})", R"({"response":"ok"})", "<html>OK</html>", R"({"response":"ok","_rxid":"bad\nvalue"})"})
|
||||
check(!accepted_response(200, response, id), "Unconfirmed submission accepted");
|
||||
check(!accepted_response(429, R"({"response":"ok","_rxid":"1234"})", id), "Rate limited submission accepted");
|
||||
check(!accepted_response(500, R"({"response":"ok","_rxid":"1234"})", id), "Failed submission accepted");
|
||||
}
|
||||
void attachment_checks(const std::filesystem::path& root) {
|
||||
const auto directory = root / L"attachments";
|
||||
std::filesystem::create_directories(directory);
|
||||
const auto log = directory / L"ReSkate.log", dump = directory / L"fixture.dmp";
|
||||
const std::string dump_bytes("MDMP\0binary\xff", 12);
|
||||
const std::string original_log = "session start\r\nlast crash message\r\n";
|
||||
std::ofstream(dump, std::ios::binary).write(dump_bytes.data(), dump_bytes.size());
|
||||
std::ofstream(log, std::ios::binary) << original_log;
|
||||
check(snapshot_log(log, dump) == LogSnapshot::complete, "Log snapshot failed");
|
||||
std::ofstream(log) << "a later game session";
|
||||
check(read(log_attachment_path(dump)) == original_log, "Queued attachment changed with live log");
|
||||
auto attributes = Json::object(); attributes["version"] = "fixture-build"; attributes["attachment.log"] = "complete";
|
||||
{
|
||||
MultipartReport body(dump, attributes, "fixture-boundary");
|
||||
check(body.error() == 0, "Cannot prepare attached report");
|
||||
std::string wire;
|
||||
check(body.write([&](const void* data, DWORD bytes) { wire.append(static_cast<const char*>(data), bytes); return true; }), "Cannot stream attached report");
|
||||
check(wire.size() == body.size(), "Multipart Content-Length does not match transmitted bytes");
|
||||
// Parse the emitted MIME parts independently to check names, boundaries and raw payloads.
|
||||
std::map<std::string, std::pair<std::string, std::string>> parts;
|
||||
const std::string delimiter = "--fixture-boundary";
|
||||
std::size_t cursor{};
|
||||
for (;;) {
|
||||
check(wire.compare(cursor, delimiter.size(), delimiter) == 0, "Malformed MIME delimiter");
|
||||
cursor += delimiter.size();
|
||||
if (wire.compare(cursor, 4, "--\r\n") == 0) { check(cursor + 4 == wire.size(), "Trailing MIME data"); break; }
|
||||
check(wire.compare(cursor, 2, "\r\n") == 0, "Missing MIME header separator"); cursor += 2;
|
||||
const auto end = wire.find("\r\n\r\n", cursor), next = wire.find("\r\n" + delimiter, end + 4);
|
||||
check(end != std::string::npos && next != std::string::npos, "Unterminated MIME part");
|
||||
const auto header = wire.substr(cursor, end - cursor);
|
||||
const auto name = header.find("name=\"");
|
||||
check(name != std::string::npos, "Missing MIME field name");
|
||||
const auto name_end = header.find('"', name + 6);
|
||||
check(name_end != std::string::npos, "Invalid MIME field name");
|
||||
check(parts.emplace(header.substr(name + 6, name_end - name - 6),
|
||||
std::make_pair(header, wire.substr(end + 4, next - end - 4))).second, "Duplicate MIME field");
|
||||
cursor = next + 2;
|
||||
}
|
||||
check(parts.size() == 5 && parts.at("version").second == "fixture-build", "Attributes lost during multipart upload");
|
||||
check(parts.at("upload_file_minidump").second == dump_bytes, "Dump bytes corrupted");
|
||||
check(parts.at("attachment_ReSkate.log").second == original_log &&
|
||||
parts.at("attachment_ReSkate.log").first.find("filename=\"ReSkate.log\"") != std::string::npos, "Log is not a named file attachment");
|
||||
check(Json::parse(parts.at("attachment_report.json").second).at("version").string() == "fixture-build", "Report details attachment missing");
|
||||
}
|
||||
{
|
||||
MultipartReport body(dump, attributes, "fixture-boundary");
|
||||
check(!body.write([](const void*, DWORD) { return false; }), "Stream failure ignored");
|
||||
}
|
||||
std::filesystem::remove(log_attachment_path(dump));
|
||||
check(MultipartReport(dump, attributes, "fixture-boundary").error() != 0, "Missing queued log silently dropped");
|
||||
attributes.erase("attachment.log");
|
||||
check(MultipartReport(dump, attributes, "fixture-boundary").error() == 0, "Legacy report without logs blocked");
|
||||
const std::string tail(max_log_bytes, 'x');
|
||||
std::ofstream(log, std::ios::binary) << "discard this old prefix" << tail;
|
||||
check(snapshot_log(log, dump) == LogSnapshot::tail && read(log_attachment_path(dump)) == tail, "Large log did not retain bounded tail");
|
||||
check(std::filesystem::file_size(log) > max_log_bytes, "Original log was truncated");
|
||||
check(snapshot_log(directory / L"missing.log", directory / L"missing.dmp") == LogSnapshot::unavailable, "Missing source log reported as attached");
|
||||
}
|
||||
void queue_checks(const std::filesystem::path& root) {
|
||||
const auto queue = root / L"queue";
|
||||
std::filesystem::create_directories(queue);
|
||||
const auto dump = queue / L"fixture.dmp";
|
||||
const auto metadata = dump.wstring() + L".json";
|
||||
std::ofstream(dump) << "synthetic test data";
|
||||
std::ofstream(metadata) << R"({"version":"original-build","attachment.log":"complete"})";
|
||||
std::ofstream(log_attachment_path(dump)) << "original-session-log";
|
||||
const Sender failure = [](const auto&, const auto&, const auto&) { return UploadResult{false, 503, 0, {}}; };
|
||||
check(upload_pending(L"test", queue, failure) == 0 && std::filesystem::exists(dump), "Failed upload did not retain dump");
|
||||
check(read(log_attachment_path(dump)) == "original-session-log", "Failed upload lost log attachment");
|
||||
const Sender success = [](const auto&, const auto& queued_dump, const Json& attributes) {
|
||||
check(attributes.at("version").string() == "original-build", "Queued report lost original build attributes");
|
||||
check(read(log_attachment_path(queued_dump)) == "original-session-log", "Retry lost original session log");
|
||||
return UploadResult{true, 200, 0, "1234-abcd"};
|
||||
};
|
||||
Handle lock(CreateFileW((queue / L"queue.lock").c_str(), GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
check(upload_pending(L"test", queue, success) == 0 && std::filesystem::exists(dump), "Queue lock failed");
|
||||
CloseHandle(lock.release());
|
||||
check(upload_pending(L"test", queue, success) == 1, "Confirmed upload not counted");
|
||||
check(!std::filesystem::exists(dump) && !std::filesystem::exists(metadata) && !std::filesystem::exists(log_attachment_path(dump)), "Confirmed report or attachment not removed");
|
||||
check(queue_directory(root, L"https://a/token/minidump") != queue_directory(root, L"https://b/token/minidump"), "Different destinations share queue");
|
||||
for (std::size_t i = 0; i < max_pending_reports + 2; ++i) {
|
||||
const auto path = queue / std::format(L"{}.dmp", i);
|
||||
std::ofstream(path) << "synthetic test data";
|
||||
std::ofstream(path.wstring() + L".json") << R"({"version":"original-build"})";
|
||||
std::ofstream(log_attachment_path(path)) << "retention-fixture";
|
||||
}
|
||||
upload_pending(L"test", queue, failure);
|
||||
std::size_t count{};
|
||||
for (const auto& entry : std::filesystem::directory_iterator(queue)) {
|
||||
if (entry.path().extension() == L".dmp") ++count;
|
||||
if (entry.path().extension() == L".log") {
|
||||
auto owner = entry.path(); owner.replace_extension();
|
||||
check(std::filesystem::exists(owner), "Retention orphaned a log attachment");
|
||||
}
|
||||
}
|
||||
check(count == max_pending_reports, "Queue retention limit not enforced");
|
||||
const auto expired = queue / L"expired.dmp";
|
||||
std::ofstream(expired) << "expired";
|
||||
std::ofstream(expired.wstring() + L".json") << "{}";
|
||||
std::ofstream(log_attachment_path(expired)) << "expired-session-log";
|
||||
std::filesystem::last_write_time(expired, std::filesystem::file_time_type::clock::now() - std::chrono::hours(24 * 15));
|
||||
upload_pending(L"test", queue, failure);
|
||||
check(!std::filesystem::exists(expired) && !std::filesystem::exists(log_attachment_path(expired)), "Expired report or attachment retained");
|
||||
}
|
||||
void crash_check(const std::filesystem::path& root, bool native) {
|
||||
std::filesystem::create_directories(root);
|
||||
std::array<wchar_t, 32768> executable{};
|
||||
GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()));
|
||||
std::filesystem::path child_path(executable.data());
|
||||
if (native) {
|
||||
child_path = root / L"Skate.exe";
|
||||
std::filesystem::copy_file(executable.data(), child_path);
|
||||
std::filesystem::copy_file(std::filesystem::path(executable.data()).parent_path() / L"ReSkateLauncher.exe", root / L"ReSkateLauncher.exe");
|
||||
}
|
||||
auto command = std::format(L"\"{}\" {} \"{}\"", child_path.wstring(), native ? L"--native-crash" : L"--crash", root.wstring());
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
check(CreateProcessW(child_path.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process) != FALSE, "Cannot start crash fixture");
|
||||
Handle child(process.hProcess), thread(process.hThread);
|
||||
check(WaitForSingleObject(child.value, 30000) == WAIT_OBJECT_0, "Crash fixture timed out");
|
||||
DWORD code{}; GetExitCodeProcess(child.value, &code);
|
||||
check(code == 42, "Previous exception handler was not preserved");
|
||||
const auto log = read(root / L"ReSkate.log");
|
||||
check(log.find("Backtrace crash reporting enabled") != std::string::npos, "Helper did not start");
|
||||
check((log.find("ReSkate unhandled exception") != std::string::npos) != native, "Fixture did not exercise the expected exception handler");
|
||||
const auto queue = queue_directory(root / L"crashes", L"https://127.0.0.1:1/test/minidump");
|
||||
std::filesystem::path dump;
|
||||
for (int attempt = 0; attempt < 100 && dump.empty(); ++attempt) {
|
||||
for (const auto& entry : std::filesystem::directory_iterator(queue)) if (entry.path().extension() == L".dmp") dump = entry.path();
|
||||
if (dump.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
check(!dump.empty(), "No out-of-process minidump captured");
|
||||
auto bytes = read(dump);
|
||||
MINIDUMP_DIRECTORY* stream{}; void* data{}; ULONG size{};
|
||||
check(MiniDumpReadDumpStream(bytes.data(), ExceptionStream, &stream, &data, &size) != FALSE, "Minidump exception stream missing");
|
||||
const auto* exception = static_cast<const MINIDUMP_EXCEPTION_STREAM*>(data);
|
||||
check(size >= sizeof(*exception) && exception->ExceptionRecord.ExceptionCode == test_exception, "Wrong exception captured");
|
||||
check(MiniDumpReadDumpStream(bytes.data(), ModuleListStream, &stream, &data, &size) != FALSE, "Minidump module list missing");
|
||||
check(Json::parse(read(dump.wstring() + L".json")).at("application").string() == "ReSkate", "Crash attributes missing");
|
||||
check(Json::parse(read(dump.wstring() + L".json")).at("capture.source").string() ==
|
||||
(native ? "game-native" : "unhandled-exception"), "Wrong capture source");
|
||||
check(Json::parse(read(dump.wstring() + L".json")).at("attachment.log").string() == "complete" &&
|
||||
read(log_attachment_path(dump)) == log, "Crash did not snapshot its session log");
|
||||
if (native) {
|
||||
const auto source = root / L"local" / L"ReSkate" / L"Game" / L"Skate" / L"CrashDumps";
|
||||
const auto pid = GetProcessId(child.value), created = process_creation_seconds(child.value);
|
||||
const auto selected = find_native_dump(source, pid, created);
|
||||
check(selected && selected->path.extension() == L".mdmp", "Native dump selection failed");
|
||||
check(!find_native_dump(source, pid + 1, created), "Imported a different process's dump");
|
||||
check(!find_native_dump(source, pid, created - 1), "Imported a different lifetime of the same PID");
|
||||
check(std::filesystem::exists(source / L"game-crash.mdmp"), "Original game dump was removed");
|
||||
std::filesystem::create_directories(root / L"invalid");
|
||||
std::ofstream(root / L"invalid" / L"bad.mdmp") << "MDMP";
|
||||
check(!find_native_dump(root / L"invalid", pid, created), "Truncated native dump accepted");
|
||||
}
|
||||
// Allow the helper's deliberately refused loopback upload to finish before fixture cleanup.
|
||||
for (int i = 0; i < 100 && !std::filesystem::exists(queue / L"status.txt"); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
check(std::filesystem::exists(dump), "Offline dump was lost");
|
||||
}
|
||||
}
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
|
||||
try {
|
||||
if (argc == 5 && std::wstring_view(argv[1]) == L"--inspect-native") {
|
||||
const auto dump = find_native_dump(argv[2], static_cast<DWORD>(std::stoul(argv[3])), static_cast<DWORD>(std::stoul(argv[4])));
|
||||
check(dump.has_value(), "No native dump matched this process lifetime");
|
||||
std::cout << "Matched native crash: process " << dump->process_id << "; exception 0x" << std::hex
|
||||
<< dump->exception_code << "; address 0x" << dump->exception_address << '\n';
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && std::wstring_view(argv[1]) == L"--submit-fixture") {
|
||||
const std::filesystem::path dump(argv[2]);
|
||||
auto bytes = read(dump);
|
||||
check(bytes.size() >= sizeof(MINIDUMP_HEADER), "Not a minidump fixture");
|
||||
MINIDUMP_DIRECTORY* stream{}; void* data{}; ULONG size{};
|
||||
check(MiniDumpReadDumpStream(bytes.data(), ExceptionStream, &stream, &data, &size) != FALSE &&
|
||||
size >= sizeof(MINIDUMP_EXCEPTION_STREAM) &&
|
||||
static_cast<const MINIDUMP_EXCEPTION_STREAM*>(data)->ExceptionRecord.ExceptionCode == test_exception,
|
||||
"Only synthetic regression-test crashes may be submitted by this command");
|
||||
auto attributes = Json::parse(read(dump.wstring() + L".json"));
|
||||
attributes["integration.test"] = "true";
|
||||
attributes["error.message"] = "Synthetic ReSkate Backtrace integration test; no game crash";
|
||||
auto url = environment(L"RESKATE_BACKTRACE_URL");
|
||||
if (url.empty()) url = configured_url;
|
||||
const auto result = upload_report(url, dump, attributes);
|
||||
std::cout << "HTTP " << result.http_status << "; Windows error " << result.error << "; report " << result.report_id << '\n';
|
||||
check(result.accepted, "Backtrace did not confirm test report");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && (std::wstring_view(argv[1]) == L"--crash" || std::wstring_view(argv[1]) == L"--native-crash")) {
|
||||
const bool native = std::wstring_view(argv[1]) == L"--native-crash";
|
||||
if (native) {
|
||||
const auto local = std::filesystem::path(argv[2]) / L"local";
|
||||
SetEnvironmentVariableW(L"LOCALAPPDATA", local.c_str());
|
||||
native_fixture_directory = local / L"ReSkate" / L"Game" / L"Skate" / L"CrashDumps";
|
||||
std::filesystem::create_directories(native_fixture_directory);
|
||||
}
|
||||
SetEnvironmentVariableW(L"RESKATE_CRASH_REPORTING", L"1");
|
||||
SetEnvironmentVariableW(L"RESKATE_BACKTRACE_URL", L"https://127.0.0.1:1/test/minidump");
|
||||
SetUnhandledExceptionFilter(finish_child);
|
||||
logging::Options options; options.directory = argv[2]; options.truncate_file = true;
|
||||
check(logging::initialize(options), "Fixture logging failed");
|
||||
if (native) SetUnhandledExceptionFilter(native_child_handler); // Reproduce the game's later handler registration.
|
||||
RaiseException(test_exception, EXCEPTION_NONCONTINUABLE, 0, nullptr);
|
||||
return 3;
|
||||
}
|
||||
check(argc == 2, "Pass a fixture root");
|
||||
const auto root = std::filesystem::path(argv[1]) / std::format(L"run-{}-{}", GetCurrentProcessId(), GetTickCount64());
|
||||
std::filesystem::create_directories(root);
|
||||
SetEnvironmentVariableW(L"RESKATE_CRASH_REPORTING", L"0");
|
||||
check(start(root) == StartResult::disabled, "Explicit opt-out ignored");
|
||||
SetEnvironmentVariableW(L"RESKATE_CRASH_REPORTING", L"1");
|
||||
SetEnvironmentVariableW(L"RESKATE_BACKTRACE_URL", L"http://invalid/post");
|
||||
check(start(root) == StartResult::failed, "Invalid endpoint enabled reporter");
|
||||
endpoint_checks();
|
||||
attachment_checks(root);
|
||||
queue_checks(root);
|
||||
crash_check(root / L"direct", false);
|
||||
crash_check(root / L"native", true);
|
||||
std::cout << "Backtrace endpoint, receipts, multipart attachments, log snapshots, retries, retention, opt-out, exception chaining, direct capture and native-handler fallback checks passed.\n";
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
enum class StartResult { disabled, ready, failed };
|
||||
// Called outside DllMain. A missing endpoint or RESKATE_CRASH_REPORTING=0 disables reporting.
|
||||
StartResult start(const std::filesystem::path& log_directory) noexcept;
|
||||
void stop() noexcept;
|
||||
// Last-chance path: only signals the prestarted helper and waits at most 15 seconds.
|
||||
void capture(EXCEPTION_POINTERS* exception) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "backtrace.h"
|
||||
#include "protocol.h"
|
||||
#include "backtrace_config.h"
|
||||
#include <algorithm>
|
||||
#include <format>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
namespace {
|
||||
std::array<HANDLE, handle_count> handles{};
|
||||
HANDLE helper{};
|
||||
SharedReport* report{};
|
||||
// 0 = ready, 1 = capture owns the IPC, 2 = stopped/initializing.
|
||||
volatile LONG capturing{2};
|
||||
struct View {
|
||||
void* value{};
|
||||
~View() { if (value) UnmapViewOfFile(value); }
|
||||
};
|
||||
template<std::size_t N> bool copy(std::array<wchar_t, N>& target, const std::wstring& value) {
|
||||
if (value.size() >= N) return false;
|
||||
std::copy(value.begin(), value.end(), target.begin());
|
||||
target[value.size()] = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
StartResult start(const std::filesystem::path& log_directory) noexcept {
|
||||
try {
|
||||
if (report) return StartResult::ready;
|
||||
if (environment(L"RESKATE_CRASH_REPORTING") == L"0") return StartResult::disabled;
|
||||
auto url = environment(L"RESKATE_BACKTRACE_URL");
|
||||
if (url.empty()) url = configured_url;
|
||||
if (url.empty()) return StartResult::disabled;
|
||||
if (!url.starts_with(L"https://") || url.size() >= SharedReport{}.url.size()) return StartResult::failed;
|
||||
HMODULE module{};
|
||||
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&handles), &module)) return StartResult::failed;
|
||||
std::array<wchar_t, 32768> module_path{}, process_path{};
|
||||
const auto module_size = GetModuleFileNameW(module, module_path.data(), static_cast<DWORD>(module_path.size()));
|
||||
const auto process_size = GetModuleFileNameW(nullptr, process_path.data(), static_cast<DWORD>(process_path.size()));
|
||||
if (!module_size || module_size >= module_path.size() || !process_size || process_size >= process_path.size())
|
||||
return StartResult::failed;
|
||||
const auto executable = std::filesystem::path(module_path.data()).parent_path() / L"ReSkateLauncher.exe";
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), nullptr, TRUE};
|
||||
std::array<Handle, handle_count> owned;
|
||||
owned[mapping].value = CreateFileMappingW(INVALID_HANDLE_VALUE, &security, PAGE_READWRITE, 0, sizeof(SharedReport), nullptr);
|
||||
for (std::size_t i = requested; i <= ready; ++i) owned[i].value = CreateEventW(&security, TRUE, FALSE, nullptr);
|
||||
if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(), GetCurrentProcess(), &owned[parent].value,
|
||||
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | SYNCHRONIZE, TRUE, 0)) return StartResult::failed;
|
||||
std::array<HANDLE, handle_count> inherited{};
|
||||
for (std::size_t i = 0; i < handle_count; ++i) {
|
||||
if (!owned[i].value) return StartResult::failed;
|
||||
inherited[i] = owned[i].value;
|
||||
}
|
||||
View view{MapViewOfFile(owned[mapping].value, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedReport))};
|
||||
if (!view.value) return StartResult::failed;
|
||||
auto* shared = new (view.value) SharedReport{};
|
||||
if (!copy(shared->url, url) || !copy(shared->directory, std::filesystem::absolute(log_directory / L"crashes").wstring()) ||
|
||||
!copy(shared->application, std::filesystem::path(process_path.data()).filename().wstring())) return StartResult::failed;
|
||||
// Frostbite handles some fatal exceptions itself and writes a native dump
|
||||
// before exiting, so those faults never reach our last-chance filter. The
|
||||
// game's LocalAppData is redirected to ReSkate\Game (Extension/Boot/user_data_redirect.cpp).
|
||||
if (_wcsicmp(shared->application.data(), L"Skate.exe") == 0) {
|
||||
const auto local = environment(L"LOCALAPPDATA");
|
||||
if (!local.empty() && !copy(shared->native_directory,
|
||||
(std::filesystem::path(local) / L"ReSkate" / L"Game" / L"Skate" / L"CrashDumps").wstring()))
|
||||
return StartResult::failed;
|
||||
}
|
||||
|
||||
SIZE_T size{};
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
|
||||
std::vector<unsigned char> storage(size);
|
||||
auto* attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
|
||||
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &size)) return StartResult::failed;
|
||||
struct Attributes { LPPROC_THREAD_ATTRIBUTE_LIST value; ~Attributes() { DeleteProcThreadAttributeList(value); } } cleanup{attributes};
|
||||
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
||||
inherited.data(), sizeof(inherited), nullptr, nullptr)) return StartResult::failed;
|
||||
auto command = std::format(L"\"{}\" --reskate-crash-helper", executable.wstring());
|
||||
for (const auto handle : inherited) command += std::format(L" {}", reinterpret_cast<ULONG_PTR>(handle));
|
||||
STARTUPINFOEXW startup{};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.lpAttributeList = attributes;
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, nullptr, executable.parent_path().c_str(),
|
||||
&startup.StartupInfo, &process)) return StartResult::failed;
|
||||
Handle child(process.hProcess), thread(process.hThread);
|
||||
// Prevent later child processes from inheriting our IPC and parent handles.
|
||||
for (const auto handle : inherited) SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0);
|
||||
const HANDLE wait_for[]{owned[ready].value, child.value};
|
||||
if (WaitForMultipleObjects(2, wait_for, FALSE, 5000) != WAIT_OBJECT_0) {
|
||||
SetEvent(owned[stopping].value);
|
||||
return StartResult::failed;
|
||||
}
|
||||
for (std::size_t i = 0; i < handle_count; ++i) handles[i] = owned[i].release();
|
||||
helper = child.release();
|
||||
report = shared;
|
||||
view.value = nullptr;
|
||||
InterlockedExchange(&capturing, 0);
|
||||
return StartResult::ready;
|
||||
} catch (...) { return StartResult::failed; }
|
||||
}
|
||||
void capture(EXCEPTION_POINTERS* exception) noexcept {
|
||||
if (!exception || InterlockedCompareExchange(&capturing, 1, 0) != 0) return;
|
||||
report->thread_id = GetCurrentThreadId();
|
||||
report->exception = exception;
|
||||
MemoryBarrier();
|
||||
if (SetEvent(handles[requested])) {
|
||||
const HANDLE wait_for[]{handles[captured], helper};
|
||||
WaitForMultipleObjects(2, wait_for, FALSE, capture_timeout_ms);
|
||||
}
|
||||
}
|
||||
void stop() noexcept {
|
||||
// An exception already using these handles owns them until process exit.
|
||||
// Never unmap its report or close its events from a concurrent shutdown.
|
||||
if (InterlockedCompareExchange(&capturing, 2, 0) != 0) return;
|
||||
SetEvent(handles[stopping]);
|
||||
UnmapViewOfFile(report);
|
||||
report = nullptr;
|
||||
for (auto& handle : handles) { CloseHandle(handle); handle = nullptr; }
|
||||
CloseHandle(helper);
|
||||
helper = nullptr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "multipart.h"
|
||||
#include "upload.h"
|
||||
#include <algorithm>
|
||||
#include <format>
|
||||
#include <limits>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
namespace {
|
||||
bool stream_file(HANDLE file, DWORD bytes, const std::function<bool(const void*, DWORD)>& sink) {
|
||||
std::array<char, 65536> buffer{};
|
||||
while (bytes) {
|
||||
DWORD read{};
|
||||
const auto wanted = std::min<DWORD>(bytes, static_cast<DWORD>(buffer.size()));
|
||||
if (!ReadFile(file, buffer.data(), wanted, &read, nullptr) || read != wanted || !sink(buffer.data(), read)) return false;
|
||||
bytes -= read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool file_length(HANDLE file, std::uintmax_t maximum, DWORD& bytes) {
|
||||
LARGE_INTEGER length{};
|
||||
if (file == INVALID_HANDLE_VALUE || !GetFileSizeEx(file, &length) || length.QuadPart < 0 ||
|
||||
static_cast<std::uintmax_t>(length.QuadPart) > maximum) return false;
|
||||
bytes = static_cast<DWORD>(length.QuadPart);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::filesystem::path log_attachment_path(const std::filesystem::path& dump) { return dump.wstring() + L".log"; }
|
||||
LogSnapshot snapshot_log(const std::filesystem::path& source, const std::filesystem::path& dump) noexcept {
|
||||
try {
|
||||
Handle input(CreateFileW(source.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
LARGE_INTEGER length{};
|
||||
if (input.value == INVALID_HANDLE_VALUE || !GetFileSizeEx(input.value, &length) || length.QuadPart < 0)
|
||||
return LogSnapshot::unavailable;
|
||||
const bool truncated = length.QuadPart > max_log_bytes;
|
||||
const auto bytes = static_cast<DWORD>(std::min<LONGLONG>(length.QuadPart, max_log_bytes));
|
||||
LARGE_INTEGER start{}; start.QuadPart = length.QuadPart - bytes;
|
||||
if (!SetFilePointerEx(input.value, start, nullptr, FILE_BEGIN)) return LogSnapshot::unavailable;
|
||||
const auto destination = log_attachment_path(dump);
|
||||
Handle output(CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
if (output.value == INVALID_HANDLE_VALUE) return LogSnapshot::unavailable;
|
||||
const auto saved = stream_file(input.value, bytes, [&](const void* data, DWORD count) {
|
||||
DWORD written{};
|
||||
return WriteFile(output.value, data, count, &written, nullptr) && written == count;
|
||||
}) && FlushFileBuffers(output.value);
|
||||
CloseHandle(output.release());
|
||||
if (!saved) { DeleteFileW(destination.c_str()); return LogSnapshot::unavailable; }
|
||||
return truncated ? LogSnapshot::tail : LogSnapshot::complete;
|
||||
} catch (...) { return LogSnapshot::unavailable; }
|
||||
}
|
||||
MultipartReport::MultipartReport(const std::filesystem::path& dump, const Json& attributes, const std::string& boundary) {
|
||||
if (!attributes.is_object() || boundary.empty() || boundary.size() > 70 ||
|
||||
boundary.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") != std::string::npos) {
|
||||
error_ = ERROR_INVALID_PARAMETER; return;
|
||||
}
|
||||
dump_.value = CreateFileW(dump.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (!file_length(dump_.value, max_dump_bytes, dump_bytes_) || !dump_bytes_) { error_ = ERROR_FILE_INVALID; return; }
|
||||
for (const auto& [key, value] : attributes.items()) {
|
||||
if (key.empty() || key.size() > 128 || key.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") != std::string::npos ||
|
||||
!value.is_string() || value.string().size() > 4096 || value.string().find(boundary) != std::string::npos) {
|
||||
error_ = ERROR_INVALID_DATA; return;
|
||||
}
|
||||
prefix_ += std::format("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, key, value.string());
|
||||
if (prefix_.size() > 65536) { error_ = ERROR_INVALID_DATA; return; }
|
||||
}
|
||||
prefix_ += std::format("--{}\r\nContent-Disposition: form-data; name=\"upload_file_minidump\"; filename=\"crash.dmp\"\r\nContent-Type: application/octet-stream\r\n\r\n", boundary);
|
||||
const auto metadata = attributes.dump();
|
||||
if (metadata.size() > 65536) { error_ = ERROR_INVALID_DATA; return; }
|
||||
attachments_ = std::format("\r\n--{}\r\nContent-Disposition: form-data; name=\"attachment_report.json\"; filename=\"report.json\"\r\nContent-Type: application/json\r\n\r\n{}", boundary, metadata);
|
||||
const auto log_status = attributes.value("attachment.log", "unavailable");
|
||||
if (log_status == "complete" || log_status == "tail") {
|
||||
log_.value = CreateFileW(log_attachment_path(dump).c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
// If a queued attachment becomes unreadable, retain the report for retry
|
||||
// instead of confirming an upload that silently omitted its log.
|
||||
if (!file_length(log_.value, max_log_bytes, log_bytes_)) { error_ = ERROR_FILE_INVALID; return; }
|
||||
attachments_ += std::format("\r\n--{}\r\nContent-Disposition: form-data; name=\"attachment_ReSkate.log\"; filename=\"ReSkate.log\"\r\nContent-Type: text/plain\r\n\r\n", boundary);
|
||||
}
|
||||
suffix_ = std::format("\r\n--{}--\r\n", boundary);
|
||||
const auto total = prefix_.size() + static_cast<std::uint64_t>(dump_bytes_) + attachments_.size() + log_bytes_ + suffix_.size();
|
||||
if (total > std::numeric_limits<DWORD>::max()) { error_ = ERROR_FILE_TOO_LARGE; return; }
|
||||
size_ = static_cast<DWORD>(total);
|
||||
}
|
||||
bool MultipartReport::write(const std::function<bool(const void*, DWORD)>& sink) {
|
||||
return !error_ && sink(prefix_.data(), static_cast<DWORD>(prefix_.size())) && stream_file(dump_.value, dump_bytes_, sink) &&
|
||||
sink(attachments_.data(), static_cast<DWORD>(attachments_.size())) && stream_file(log_.value, log_bytes_, sink) &&
|
||||
sink(suffix_.data(), static_cast<DWORD>(suffix_.size()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include "protocol.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
inline constexpr DWORD max_log_bytes = 8 * 1024 * 1024;
|
||||
enum class LogSnapshot { unavailable, complete, tail };
|
||||
std::filesystem::path log_attachment_path(const std::filesystem::path& dump);
|
||||
// Snapshot before publishing the dump; retrying must never read a later session's log.
|
||||
LogSnapshot snapshot_log(const std::filesystem::path& source, const std::filesystem::path& dump) noexcept;
|
||||
|
||||
// Keeps the queued files open and unchanged until the complete request is sent.
|
||||
class MultipartReport {
|
||||
public:
|
||||
MultipartReport(const std::filesystem::path& dump, const Json& attributes, const std::string& boundary);
|
||||
DWORD error() const { return error_; }
|
||||
DWORD size() const { return size_; }
|
||||
bool write(const std::function<bool(const void*, DWORD)>& sink);
|
||||
private:
|
||||
Handle dump_, log_;
|
||||
DWORD dump_bytes_{}, log_bytes_{}, size_{}, error_{};
|
||||
std::string prefix_, attachments_, suffix_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "native_dump.h"
|
||||
#include "protocol.h"
|
||||
#include "upload.h"
|
||||
#include <DbgHelp.h>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
namespace {
|
||||
template<class T> bool read(std::span<const std::byte> bytes, std::size_t offset, T& value) {
|
||||
if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) return false;
|
||||
std::memcpy(&value, bytes.data() + offset, sizeof(T));
|
||||
return true;
|
||||
}
|
||||
std::optional<NativeDump> inspect(const std::filesystem::path& path, DWORD process_id, DWORD process_created) {
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
LARGE_INTEGER size{};
|
||||
if (file.value == INVALID_HANDLE_VALUE || !GetFileSizeEx(file.value, &size) ||
|
||||
size.QuadPart < sizeof(MINIDUMP_HEADER) || static_cast<std::uintmax_t>(size.QuadPart) > max_dump_bytes) return {};
|
||||
Handle mapping(CreateFileMappingW(file.value, nullptr, PAGE_READONLY, 0, 0, nullptr));
|
||||
if (!mapping.value) return {};
|
||||
auto* data = static_cast<const std::byte*>(MapViewOfFile(mapping.value, FILE_MAP_READ, 0, 0, 0));
|
||||
if (!data) return {};
|
||||
struct View { const void* data; ~View() { UnmapViewOfFile(data); } } view{data};
|
||||
const std::span bytes(data, static_cast<std::size_t>(size.QuadPart));
|
||||
MINIDUMP_HEADER header{};
|
||||
if (!read(bytes, 0, header) || header.Signature != MINIDUMP_SIGNATURE ||
|
||||
(header.Version & 0xffff) != MINIDUMP_VERSION || header.NumberOfStreams > 128 ||
|
||||
header.TimeDateStamp < process_created) return {};
|
||||
MINIDUMP_MISC_INFO misc{};
|
||||
MINIDUMP_EXCEPTION_STREAM exception{};
|
||||
bool have_misc{}, have_exception{};
|
||||
for (DWORD i = 0; i < header.NumberOfStreams; ++i) {
|
||||
MINIDUMP_DIRECTORY entry{};
|
||||
if (!read(bytes, static_cast<std::size_t>(header.StreamDirectoryRva) + i * sizeof(entry), entry) ||
|
||||
entry.Location.Rva > bytes.size() || entry.Location.DataSize > bytes.size() - entry.Location.Rva) return {};
|
||||
if (entry.StreamType == MiscInfoStream && entry.Location.DataSize >= sizeof(misc))
|
||||
have_misc = read(bytes, entry.Location.Rva, misc);
|
||||
if (entry.StreamType == ExceptionStream && entry.Location.DataSize >= sizeof(exception))
|
||||
have_exception = read(bytes, entry.Location.Rva, exception);
|
||||
}
|
||||
constexpr DWORD required = MINIDUMP_MISC1_PROCESS_ID | MINIDUMP_MISC1_PROCESS_TIMES;
|
||||
if (!have_misc || !have_exception || (misc.Flags1 & required) != required || misc.ProcessId != process_id ||
|
||||
misc.ProcessCreateTime != process_created || !exception.ThreadId || !exception.ExceptionRecord.ExceptionCode) return {};
|
||||
return NativeDump{path, misc.ProcessId, misc.ProcessCreateTime, exception.ThreadId,
|
||||
exception.ExceptionRecord.ExceptionCode, header.TimeDateStamp, exception.ExceptionRecord.ExceptionAddress};
|
||||
}
|
||||
}
|
||||
DWORD process_creation_seconds(HANDLE process) noexcept {
|
||||
FILETIME created{}, exited{}, kernel{}, user{};
|
||||
if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) return 0;
|
||||
ULARGE_INTEGER time{};
|
||||
time.LowPart = created.dwLowDateTime; time.HighPart = created.dwHighDateTime;
|
||||
constexpr auto epoch = 11644473600ull;
|
||||
const auto seconds = time.QuadPart / 10000000ull;
|
||||
return seconds >= epoch ? static_cast<DWORD>(seconds - epoch) : 0;
|
||||
}
|
||||
std::optional<NativeDump> find_native_dump(const std::filesystem::path& directory,
|
||||
DWORD process_id, DWORD process_created) noexcept {
|
||||
try {
|
||||
if (directory.empty() || !process_id || !process_created) return {};
|
||||
std::optional<NativeDump> selected;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory)) {
|
||||
const auto extension = entry.path().extension();
|
||||
if ((extension != L".mdmp" && extension != L".dmp") || !entry.is_regular_file()) continue;
|
||||
auto candidate = inspect(entry.path(), process_id, process_created);
|
||||
if (!candidate) continue;
|
||||
if (!selected || candidate->timestamp < selected->timestamp ||
|
||||
(candidate->timestamp == selected->timestamp && extension == L".mdmp" && selected->path.extension() != L".mdmp"))
|
||||
selected = std::move(candidate);
|
||||
}
|
||||
return selected;
|
||||
} catch (...) { return {}; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
struct NativeDump {
|
||||
std::filesystem::path path;
|
||||
DWORD process_id{}, process_created{}, thread_id{}, exception_code{}, timestamp{};
|
||||
ULONG64 exception_address{};
|
||||
};
|
||||
// Match the exact process lifetime, not filenames or modification times. The game
|
||||
// can emit several dumps for one fault; choose the first, preferring its rich mdmp.
|
||||
std::optional<NativeDump> find_native_dump(const std::filesystem::path& directory,
|
||||
DWORD process_id, DWORD process_created) noexcept;
|
||||
DWORD process_creation_seconds(HANDLE process) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
inline constexpr DWORD protocol_version = 2;
|
||||
inline constexpr DWORD capture_timeout_ms = 15000;
|
||||
struct SharedReport {
|
||||
DWORD version{protocol_version};
|
||||
DWORD thread_id{};
|
||||
EXCEPTION_POINTERS* exception{}; // Address in the parent; MiniDumpWriteDump uses ClientPointers=TRUE.
|
||||
std::array<wchar_t, 4096> url{};
|
||||
std::array<wchar_t, 32768> directory{};
|
||||
std::array<wchar_t, 260> application{};
|
||||
std::array<wchar_t, 32768> native_directory{};
|
||||
};
|
||||
// Explicitly inherited unnamed handles; no token or paths on the command line.
|
||||
enum HandleIndex : std::size_t { mapping, requested, captured, stopping, ready, parent, handle_count };
|
||||
struct Handle {
|
||||
HANDLE value{};
|
||||
Handle() = default;
|
||||
explicit Handle(HANDLE handle) : value(handle) {}
|
||||
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
|
||||
Handle(const Handle&) = delete;
|
||||
Handle& operator=(const Handle&) = delete;
|
||||
HANDLE release() { const auto result = value; value = nullptr; return result; }
|
||||
};
|
||||
inline std::wstring environment(const wchar_t* name) {
|
||||
std::array<wchar_t, 32768> buffer{};
|
||||
const auto size = GetEnvironmentVariableW(name, buffer.data(), static_cast<DWORD>(buffer.size()));
|
||||
return size && size < buffer.size() ? std::wstring(buffer.data(), size) : std::wstring{};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#include <Windows.h>
|
||||
#include <winhttp.h>
|
||||
#include <bcrypt.h>
|
||||
#include "upload.h"
|
||||
#include "protocol.h"
|
||||
#include "multipart.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <format>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
namespace {
|
||||
struct Internet {
|
||||
HINTERNET value{};
|
||||
~Internet() { if (value) WinHttpCloseHandle(value); }
|
||||
};
|
||||
std::string utf8(std::wstring_view value) {
|
||||
const auto size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (!size) throw std::runtime_error("Invalid text");
|
||||
std::string result(static_cast<std::size_t>(size), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
void remove_report(const std::filesystem::path& dump) {
|
||||
std::error_code error;
|
||||
std::filesystem::remove(dump, error);
|
||||
if (!error) {
|
||||
std::filesystem::remove(dump.wstring() + L".json", error);
|
||||
std::filesystem::remove(log_attachment_path(dump), error);
|
||||
}
|
||||
}
|
||||
void status_file(const std::filesystem::path& directory, const std::string& text) {
|
||||
// Overwrite a bounded status file. Never include the endpoint, token, response body, or local paths.
|
||||
std::ofstream(directory / L"status.txt", std::ios::trunc) << text << '\n';
|
||||
}
|
||||
}
|
||||
bool parse_endpoint(const std::wstring& url, Endpoint& endpoint) {
|
||||
if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring::npos) return false;
|
||||
URL_COMPONENTS parts{};
|
||||
parts.dwStructSize = sizeof(parts);
|
||||
parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength =
|
||||
parts.dwUserNameLength = parts.dwPasswordLength = static_cast<DWORD>(-1);
|
||||
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts) ||
|
||||
parts.nScheme != INTERNET_SCHEME_HTTPS || !parts.dwHostNameLength || parts.dwUserNameLength || parts.dwPasswordLength ||
|
||||
!parts.dwUrlPathLength) return false;
|
||||
endpoint.host.assign(parts.lpszHostName, parts.dwHostNameLength);
|
||||
endpoint.resource.assign(parts.lpszUrlPath, parts.dwUrlPathLength);
|
||||
if (parts.dwExtraInfoLength) endpoint.resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength);
|
||||
endpoint.port = parts.nPort;
|
||||
return true;
|
||||
}
|
||||
bool accepted_response(DWORD status, std::string_view response, std::string& report_id) {
|
||||
report_id.clear();
|
||||
if (status < 200 || status >= 300) return false;
|
||||
try {
|
||||
const auto json = Json::parse(response, {65536, 8, 256});
|
||||
if (json.value("response", "") != "ok") return false;
|
||||
const auto id = json.value("_rxid", "");
|
||||
if (id.empty() || id.size() > 128 || id.find_first_not_of("0123456789abcdefABCDEF-") != std::string::npos) return false;
|
||||
report_id = id;
|
||||
return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
std::filesystem::path queue_directory(const std::filesystem::path& root, const std::wstring& url) {
|
||||
// Hash the destination so changing projects cannot send an old queue to a new account.
|
||||
const auto encoded = utf8(url);
|
||||
std::array<unsigned char, 32> hash{};
|
||||
if (BCryptHash(BCRYPT_SHA256_ALG_HANDLE, nullptr, 0,
|
||||
reinterpret_cast<PUCHAR>(const_cast<char*>(encoded.data())), static_cast<ULONG>(encoded.size()),
|
||||
hash.data(), static_cast<ULONG>(hash.size())) < 0) throw std::runtime_error("Cannot identify report queue");
|
||||
std::wstring name;
|
||||
for (const auto byte : hash) name += std::format(L"{:02x}", byte);
|
||||
return root / name;
|
||||
}
|
||||
UploadResult upload_report(const std::wstring& url, const std::filesystem::path& dump, const Json& attributes) {
|
||||
UploadResult result;
|
||||
Endpoint endpoint;
|
||||
if (!parse_endpoint(url, endpoint) || !attributes.is_object()) { result.error = ERROR_INVALID_PARAMETER; return result; }
|
||||
const auto boundary = std::format("ReSkate-{:x}-{:x}", GetCurrentProcessId(), GetTickCount64());
|
||||
MultipartReport body(dump, attributes, boundary);
|
||||
if (body.error()) { result.error = body.error(); return result; }
|
||||
Internet session{WinHttpOpen(L"ReSkate-CrashReporter/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0)};
|
||||
if (!session.value) { result.error = GetLastError(); return result; }
|
||||
if (!WinHttpSetTimeouts(session.value, 5000, 5000, 10000, 10000)) { result.error = GetLastError(); return result; }
|
||||
Internet connection{WinHttpConnect(session.value, endpoint.host.c_str(), endpoint.port, 0)};
|
||||
if (!connection.value) { result.error = GetLastError(); return result; }
|
||||
Internet request{WinHttpOpenRequest(connection.value, L"POST", endpoint.resource.c_str(), nullptr, nullptr,
|
||||
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)};
|
||||
if (!request.value) { result.error = GetLastError(); return result; }
|
||||
DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_NEVER, logon = WINHTTP_AUTOLOGON_SECURITY_LEVEL_HIGH;
|
||||
if (!WinHttpSetOption(request.value, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)) ||
|
||||
!WinHttpSetOption(request.value, WINHTTP_OPTION_AUTOLOGON_POLICY, &logon, sizeof(logon))) {
|
||||
result.error = GetLastError(); return result;
|
||||
}
|
||||
const std::wstring wide_boundary(boundary.begin(), boundary.end());
|
||||
const auto headers = L"Content-Type: multipart/form-data; boundary=" + wide_boundary + L"\r\n";
|
||||
if (!WinHttpSendRequest(request.value, headers.c_str(), static_cast<DWORD>(headers.size()), nullptr, 0, body.size(), 0)) {
|
||||
result.error = GetLastError(); return result;
|
||||
}
|
||||
const auto started = GetTickCount64();
|
||||
const auto write = [&](const void* data, DWORD bytes) {
|
||||
DWORD written{};
|
||||
return GetTickCount64() - started < 60000 && WinHttpWriteData(request.value, data, bytes, &written) && written == bytes;
|
||||
};
|
||||
if (!body.write(write)) { result.error = ERROR_WRITE_FAULT; return result; }
|
||||
if (!WinHttpReceiveResponse(request.value, nullptr)) {
|
||||
result.error = GetLastError(); return result;
|
||||
}
|
||||
DWORD status_size = sizeof(result.http_status);
|
||||
if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, nullptr,
|
||||
&result.http_status, &status_size, nullptr)) { result.error = GetLastError(); return result; }
|
||||
std::string response;
|
||||
std::array<char, 65536> buffer{};
|
||||
for (;;) {
|
||||
DWORD read{};
|
||||
if (GetTickCount64() - started >= 60000 || !WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) {
|
||||
result.error = ERROR_TIMEOUT; return result;
|
||||
}
|
||||
if (!read) break;
|
||||
if (response.size() + read > 65536) { result.error = ERROR_INVALID_DATA; return result; }
|
||||
response.append(buffer.data(), read);
|
||||
}
|
||||
result.accepted = accepted_response(result.http_status, response, result.report_id);
|
||||
return result;
|
||||
}
|
||||
std::size_t upload_pending(const std::wstring& url, const std::filesystem::path& directory, const Sender& sender) noexcept {
|
||||
try {
|
||||
Handle lock(CreateFileW((directory / L"queue.lock").c_str(), GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
if (lock.value == INVALID_HANDLE_VALUE) return 0;
|
||||
std::vector<std::filesystem::directory_entry> pending;
|
||||
const auto cutoff = std::filesystem::file_time_type::clock::now() - std::chrono::hours(24 * 14);
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory)) {
|
||||
if (!entry.is_regular_file() || entry.path().extension() != L".dmp") continue;
|
||||
if (entry.last_write_time() < cutoff || entry.file_size() > max_dump_bytes || !entry.file_size()) remove_report(entry.path());
|
||||
else pending.push_back(entry);
|
||||
}
|
||||
std::sort(pending.begin(), pending.end(), [](const auto& a, const auto& b) { return a.last_write_time() < b.last_write_time(); });
|
||||
while (pending.size() > max_pending_reports) { remove_report(pending.front().path()); pending.erase(pending.begin()); }
|
||||
std::size_t uploaded{};
|
||||
for (const auto& entry : pending) {
|
||||
const auto metadata = entry.path().wstring() + L".json";
|
||||
std::error_code error;
|
||||
const auto size = std::filesystem::file_size(metadata, error);
|
||||
if (error || size > 65536) continue;
|
||||
std::ifstream input(metadata, std::ios::binary);
|
||||
std::string contents(static_cast<std::size_t>(size), '\0');
|
||||
if (!input.read(contents.data(), static_cast<std::streamsize>(contents.size()))) continue;
|
||||
input.close(); // Windows cannot remove an open metadata file after confirmation.
|
||||
Json attributes;
|
||||
try { attributes = Json::parse(contents, {65536, 8, 256}); } catch (...) { continue; }
|
||||
const auto result = sender(url, entry.path(), attributes);
|
||||
if (!result.accepted) {
|
||||
status_file(directory, std::format("Upload pending; HTTP {}; Windows error {}. Retrying on next launch.", result.http_status, result.error));
|
||||
break;
|
||||
}
|
||||
status_file(directory, "Accepted by Backtrace: " + result.report_id);
|
||||
remove_report(entry.path());
|
||||
if (++uploaded == 4) break; // Bound work for a single launch.
|
||||
}
|
||||
return uploaded;
|
||||
} catch (...) { return 0; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <Windows.h>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::backtrace {
|
||||
inline constexpr std::uintmax_t max_dump_bytes = 128ull * 1024 * 1024;
|
||||
inline constexpr std::size_t max_pending_reports = 20;
|
||||
struct Endpoint { std::wstring host, resource; USHORT port{}; };
|
||||
struct UploadResult { bool accepted{}; DWORD http_status{}, error{}; std::string report_id; };
|
||||
bool parse_endpoint(const std::wstring& url, Endpoint& endpoint);
|
||||
bool accepted_response(DWORD status, std::string_view response, std::string& report_id);
|
||||
std::filesystem::path queue_directory(const std::filesystem::path& root, const std::wstring& url);
|
||||
UploadResult upload_report(const std::wstring& url, const std::filesystem::path& dump, const Json& attributes);
|
||||
using Sender = std::function<UploadResult(const std::wstring&, const std::filesystem::path&, const Json&)>;
|
||||
// Cross-process queue lock prevents the launcher and game helpers submitting the same dump.
|
||||
std::size_t upload_pending(const std::wstring& url, const std::filesystem::path& directory,
|
||||
const Sender& sender = upload_report) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
#include "hooks.h"
|
||||
#include <detours.h>
|
||||
#include <TlHelp32.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
thread_local HookFailure last_failure;
|
||||
LONG failure(const char* operation, LONG error, DWORD thread = 0) {
|
||||
last_failure = {operation, error, thread}; return error;
|
||||
}
|
||||
struct Hook {
|
||||
void* target{};
|
||||
void* replacement{};
|
||||
void* relay{};
|
||||
void** original{};
|
||||
bool attached{}, queued{}, retired{};
|
||||
};
|
||||
struct Registry {
|
||||
std::mutex mutex;
|
||||
bool initialized{};
|
||||
std::map<void*, Hook*> targets;
|
||||
std::vector<std::unique_ptr<Hook>> lifetime;
|
||||
};
|
||||
Registry& registry() {
|
||||
// Native callbacks can retain originals until process teardown. Deliberately
|
||||
// retain their tiny relays; freeing them on remove/shutdown would invalidate
|
||||
// function pointers that feature code already published.
|
||||
static auto* value = new Registry;
|
||||
return *value;
|
||||
}
|
||||
HookStatus status(LONG error) { return static_cast<HookStatus>(error); }
|
||||
bool executable(void* address) {
|
||||
MEMORY_BASIC_INFORMATION page{};
|
||||
if (!address || !VirtualQuery(address, &page, sizeof(page)) || page.State != MEM_COMMIT ||
|
||||
(page.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false;
|
||||
const auto protection = page.Protect & 0xff;
|
||||
return protection == PAGE_EXECUTE || protection == PAGE_EXECUTE_READ ||
|
||||
protection == PAGE_EXECUTE_READWRITE || protection == PAGE_EXECUTE_WRITECOPY;
|
||||
}
|
||||
HookStatus create_relay(Hook& hook) {
|
||||
SYSTEM_INFO system{}; GetSystemInfo(&system);
|
||||
const auto page = static_cast<std::size_t>(system.dwPageSize);
|
||||
auto* allocation = static_cast<std::byte*>(VirtualAlloc(nullptr, page * 2,
|
||||
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
|
||||
if (!allocation) return HookOutOfMemory;
|
||||
// jmp qword ptr [rip+disp32]. A separate writable page holds the pointer
|
||||
// managed by Detours. No register/stack changes, and no RWX code page.
|
||||
std::array<std::byte, 6> jump{std::byte{0xff}, std::byte{0x25}};
|
||||
const auto displacement = static_cast<std::int32_t>(page - jump.size());
|
||||
std::memcpy(jump.data() + 2, &displacement, sizeof(displacement));
|
||||
std::memcpy(allocation, jump.data(), jump.size());
|
||||
auto** pointer = reinterpret_cast<void**>(allocation + page);
|
||||
*pointer = hook.target;
|
||||
DWORD old{};
|
||||
if (!VirtualProtect(allocation, page, PAGE_EXECUTE_READ, &old) ||
|
||||
!FlushInstructionCache(GetCurrentProcess(), allocation, jump.size())) {
|
||||
const auto error = GetLastError(); VirtualFree(allocation, 0, MEM_RELEASE); return status(error);
|
||||
}
|
||||
hook.relay = allocation; hook.original = pointer;
|
||||
return HookOk;
|
||||
}
|
||||
struct Threads {
|
||||
std::vector<HANDLE> handles;
|
||||
~Threads() { for (const auto thread : handles) CloseHandle(thread); }
|
||||
LONG collect() {
|
||||
const auto snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
||||
if (snapshot == INVALID_HANDLE_VALUE) return failure("enumerate threads", static_cast<LONG>(GetLastError()));
|
||||
THREADENTRY32 entry{}; entry.dwSize = sizeof(entry);
|
||||
LONG error = NO_ERROR;
|
||||
if (!Thread32First(snapshot, &entry)) error = static_cast<LONG>(GetLastError());
|
||||
else do {
|
||||
if (entry.th32OwnerProcessID != GetCurrentProcessId() || entry.th32ThreadID == GetCurrentThreadId()) continue;
|
||||
const auto thread = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT | THREAD_QUERY_LIMITED_INFORMATION | SYNCHRONIZE,
|
||||
FALSE, entry.th32ThreadID);
|
||||
if (!thread) {
|
||||
const auto failure = GetLastError();
|
||||
if (failure == ERROR_INVALID_PARAMETER) continue; // Thread exited during enumeration.
|
||||
// A terminating thread can reject full access while its object
|
||||
// is still in the snapshot. Skip only an independently verified
|
||||
// signaled thread; a live inaccessible thread must fail safely.
|
||||
const auto check = OpenThread(SYNCHRONIZE, FALSE, entry.th32ThreadID);
|
||||
const bool exited = check && WaitForSingleObject(check, 0) == WAIT_OBJECT_0;
|
||||
if (check) CloseHandle(check);
|
||||
if (exited) continue;
|
||||
error = dingosdk::failure("open thread", static_cast<LONG>(failure), entry.th32ThreadID); break;
|
||||
}
|
||||
const auto owner = GetProcessIdOfThread(thread);
|
||||
if (!owner) {
|
||||
const auto query_error = GetLastError();
|
||||
const bool exited = WaitForSingleObject(thread, 0) == WAIT_OBJECT_0;
|
||||
CloseHandle(thread);
|
||||
if (exited) continue;
|
||||
error = failure("query thread owner", static_cast<LONG>(query_error), entry.th32ThreadID); break;
|
||||
}
|
||||
if (owner != GetCurrentProcessId()) { CloseHandle(thread); continue; }
|
||||
try { handles.push_back(thread); }
|
||||
catch (...) { CloseHandle(thread); CloseHandle(snapshot); throw; }
|
||||
} while (Thread32Next(snapshot, &entry));
|
||||
if (error == NO_ERROR && GetLastError() != ERROR_NO_MORE_FILES)
|
||||
error = static_cast<LONG>(GetLastError());
|
||||
CloseHandle(snapshot);
|
||||
if (error != NO_ERROR && !last_failure.error) failure("enumerate threads", error);
|
||||
return error;
|
||||
}
|
||||
};
|
||||
struct Change { Hook* hook; bool attach; };
|
||||
HookStatus transact(const std::vector<Change>& changes) {
|
||||
if (changes.empty()) return HookOk;
|
||||
// Enumerate/open handles and allocate all project-owned storage before any
|
||||
// thread is suspended. The declared Detours vendor patch keeps its transaction
|
||||
// records off the shared heap for the same reason, including commit/abort.
|
||||
for (unsigned attempt = 0; attempt < 4; ++attempt) {
|
||||
Threads threads;
|
||||
auto error = threads.collect();
|
||||
if (error != NO_ERROR) return status(error);
|
||||
error = DetourTransactionBegin();
|
||||
if (error != NO_ERROR) return status(failure("begin transaction", error));
|
||||
for (const auto& change : changes) {
|
||||
error = change.attach ? DetourAttach(change.hook->original, change.hook->replacement)
|
||||
: DetourDetach(change.hook->original, change.hook->replacement);
|
||||
if (error != NO_ERROR) {
|
||||
DetourTransactionAbort();
|
||||
return status(failure(change.attach ? "attach target" : "detach target", error));
|
||||
}
|
||||
}
|
||||
bool retry = false;
|
||||
for (const auto thread : threads.handles) {
|
||||
if (WaitForSingleObject(thread, 0) == WAIT_OBJECT_0) continue;
|
||||
error = DetourUpdateThread(thread);
|
||||
if (error == NO_ERROR) continue;
|
||||
const auto thread_id = GetThreadId(thread);
|
||||
const bool exited = WaitForSingleObject(thread, 0) == WAIT_OBJECT_0;
|
||||
// UpdateThread poisons the transaction on failure. Abort/resume all
|
||||
// threads, then recollect; never commit after a failed enlistment.
|
||||
DetourTransactionAbort();
|
||||
if (!exited || attempt == 3) return status(failure("enlist thread", error, thread_id));
|
||||
retry = true;
|
||||
break;
|
||||
}
|
||||
if (retry) continue;
|
||||
error = DetourTransactionCommit();
|
||||
if (error != NO_ERROR) return status(failure("commit transaction", error));
|
||||
for (const auto& change : changes) change.hook->attached = change.attach;
|
||||
return HookOk;
|
||||
}
|
||||
return HookProtectionFailed;
|
||||
}
|
||||
template<class F> HookStatus locked(F action) {
|
||||
try {
|
||||
auto& r = registry(); std::lock_guard lock(r.mutex);
|
||||
last_failure = {};
|
||||
return action(r);
|
||||
} catch (const std::bad_alloc&) { return HookOutOfMemory; }
|
||||
}
|
||||
Hook* find(Registry& r, void* target) {
|
||||
const auto found = r.targets.find(target);
|
||||
return found == r.targets.end() ? nullptr : found->second;
|
||||
}
|
||||
}
|
||||
|
||||
HookStatus WINAPI hook_initialize() {
|
||||
return locked([](Registry& r) {
|
||||
if (r.initialized) return HookAlreadyInitialized;
|
||||
r.initialized = true; return HookOk;
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_prepare(void* target, void* replacement, void** original) {
|
||||
if (original) *original = nullptr;
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
if (!executable(target) || !executable(replacement) || target == replacement) return HookNotExecutable;
|
||||
if (r.targets.contains(target)) return HookAlreadyPrepared;
|
||||
auto hook = std::make_unique<Hook>();
|
||||
hook->target = target; hook->replacement = replacement;
|
||||
// Reserve containers before allocating the executable relay, so failure
|
||||
// cannot strand an unpublished allocation or a half-registered target.
|
||||
if (r.lifetime.size() == r.lifetime.capacity())
|
||||
r.lifetime.reserve(std::max<std::size_t>(16, r.lifetime.capacity() * 2));
|
||||
const auto [entry, inserted] = r.targets.emplace(target, hook.get());
|
||||
(void)inserted;
|
||||
const auto result = create_relay(*hook);
|
||||
if (result != HookOk) { r.targets.erase(entry); return result; }
|
||||
if (original) *original = hook->relay;
|
||||
r.lifetime.push_back(std::move(hook));
|
||||
return HookOk;
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_enable(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
auto* hook = find(r, target); if (!hook) return HookNotFound;
|
||||
if (hook->attached) return HookOk;
|
||||
return transact({{hook, true}});
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_disable(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
auto* hook = find(r, target); if (!hook) return HookNotFound;
|
||||
if (!hook->attached) return HookDisabled;
|
||||
return transact({{hook, false}});
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_remove(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
auto* hook = find(r, target); if (!hook) return HookNotFound;
|
||||
if (hook->attached) {
|
||||
const auto result = transact({{hook, false}});
|
||||
if (result != HookOk) return result;
|
||||
}
|
||||
hook->retired = true; hook->queued = false; r.targets.erase(target);
|
||||
return HookOk;
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_queue_enable(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
auto* hook = find(r, target); if (!hook) return HookNotFound;
|
||||
hook->queued = true; return HookOk;
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_apply_queued() {
|
||||
return locked([](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
std::vector<Change> changes;
|
||||
for (const auto& [target, hook] : r.targets) {
|
||||
(void)target;
|
||||
if (hook->queued && !hook->attached) changes.push_back({hook, true});
|
||||
}
|
||||
const auto result = transact(changes);
|
||||
if (result == HookOk) for (const auto& [target, hook] : r.targets) { (void)target; hook->queued = false; }
|
||||
return result;
|
||||
});
|
||||
}
|
||||
HookStatus WINAPI hook_shutdown() {
|
||||
return locked([](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
std::vector<Change> changes;
|
||||
for (const auto& [target, hook] : r.targets) { (void)target; if (hook->attached) changes.push_back({hook, false}); }
|
||||
const auto result = transact(changes);
|
||||
if (result != HookOk) return result;
|
||||
for (const auto& [target, hook] : r.targets) { (void)target; hook->retired = true; hook->queued = false; }
|
||||
r.targets.clear(); r.initialized = false; return HookOk;
|
||||
});
|
||||
}
|
||||
HookFailure hook_last_failure() noexcept { return last_failure; }
|
||||
const char* WINAPI hook_status_string(HookStatus value) {
|
||||
switch (value) {
|
||||
case HookOk: return "success";
|
||||
case HookNotInitialized: return "hook service not initialized";
|
||||
case HookAlreadyInitialized: return "hook service already initialized";
|
||||
case HookAlreadyPrepared: return "target already registered";
|
||||
case HookDisabled: return "hook already detached";
|
||||
case HookNotFound: return "target not registered";
|
||||
case HookNotExecutable: return "target or replacement is not executable";
|
||||
case HookOutOfMemory: return "out of memory";
|
||||
case HookProtectionFailed: return "memory protection or thread access denied";
|
||||
case HookUnsupportedFunction: return "Detours cannot relocate target";
|
||||
default: return "Detours transaction failed (Win32 status)";
|
||||
}
|
||||
}
|
||||
HookStatistics hook_statistics() {
|
||||
auto& r = registry(); std::lock_guard lock(r.mutex);
|
||||
HookStatistics result;
|
||||
for (const auto& hook : r.lifetime) {
|
||||
if (hook->retired) ++result.retired;
|
||||
else { ++result.prepared; if (hook->attached) ++result.attached; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <cstddef>
|
||||
|
||||
namespace dingosdk {
|
||||
// Win32 failures from Detours are preserved; negative values describe the
|
||||
// SDK-owned lifecycle. No vendor status codes escape through feature code.
|
||||
enum HookStatus : LONG {
|
||||
HookOk = NO_ERROR,
|
||||
HookNotInitialized = -1,
|
||||
HookAlreadyInitialized = -2,
|
||||
HookAlreadyPrepared = -3,
|
||||
HookDisabled = -4,
|
||||
HookNotFound = -5,
|
||||
HookNotExecutable = -6,
|
||||
HookOutOfMemory = ERROR_NOT_ENOUGH_MEMORY,
|
||||
HookProtectionFailed = ERROR_ACCESS_DENIED,
|
||||
HookUnsupportedFunction = ERROR_INVALID_BLOCK,
|
||||
};
|
||||
|
||||
// A prepared original is a permanent x64 relay. Detours changes its pointer
|
||||
// transactionally, so originals published into atomics or retained by native
|
||||
// callbacks stay callable before attach and after detach. Only Detours performs
|
||||
// instruction relocation and target patching. Preparation never patches target.
|
||||
HookStatus WINAPI hook_initialize();
|
||||
// original may be null when the replacement never forwards to the target.
|
||||
HookStatus WINAPI hook_prepare(void* target, void* replacement, void** original = nullptr);
|
||||
HookStatus WINAPI hook_enable(void* target);
|
||||
HookStatus WINAPI hook_disable(void* target);
|
||||
HookStatus WINAPI hook_remove(void* target);
|
||||
HookStatus WINAPI hook_queue_enable(void* target);
|
||||
HookStatus WINAPI hook_apply_queued();
|
||||
HookStatus WINAPI hook_shutdown();
|
||||
const char* WINAPI hook_status_string(HookStatus status);
|
||||
// Captured on the calling thread; formatting/logging happens only after Detours
|
||||
// has resumed enlisted threads. Read before issuing another hook operation.
|
||||
struct HookFailure { const char* operation{"none"}; LONG error{}; DWORD thread_id{}; };
|
||||
HookFailure hook_last_failure() noexcept;
|
||||
|
||||
struct HookStatistics { std::size_t prepared{}, attached{}, retired{}; };
|
||||
HookStatistics hook_statistics();
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "json.h"
|
||||
#include <cmath>
|
||||
#include <rapidjson/error/en.h>
|
||||
#include <rapidjson/memorystream.h>
|
||||
#include <rapidjson/prettywriter.h>
|
||||
#include <rapidjson/reader.h>
|
||||
#include <rapidjson/stringbuffer.h>
|
||||
|
||||
namespace dingosdk {
|
||||
Json::Json(std::initializer_list<Json> values) {
|
||||
const bool fields = std::all_of(values.begin(), values.end(), [](const Json& entry) {
|
||||
return entry.is_array() && entry.size() == 2 && entry[0].is_string();
|
||||
});
|
||||
if (fields) {
|
||||
data_ = Object{};
|
||||
for (const auto& entry : values) items().emplace(entry[0].string(), entry[1]);
|
||||
} else data_ = Array(values);
|
||||
}
|
||||
|
||||
bool Json::operator==(const Json& other) const {
|
||||
if (data_.index() == other.data_.index()) return data_ == other.data_;
|
||||
if (!is_number() || !other.is_number()) return false;
|
||||
if (is_number_float() || other.is_number_float()) return get<double>() == other.get<double>();
|
||||
if (is_number_unsigned()) return std::cmp_equal(get<std::uint64_t>(), other.get<std::int64_t>());
|
||||
return std::cmp_equal(get<std::int64_t>(), other.get<std::uint64_t>());
|
||||
}
|
||||
bool Json::operator<(const Json& other) const {
|
||||
if (!is_number() || !other.is_number()) throw std::runtime_error("JSON ordering requires numbers");
|
||||
if (is_number_float() || other.is_number_float()) return get<double>() < other.get<double>();
|
||||
if (is_number_unsigned()) {
|
||||
if (other.is_number_unsigned()) return get<std::uint64_t>() < other.get<std::uint64_t>();
|
||||
return std::cmp_less(get<std::uint64_t>(), other.get<std::int64_t>());
|
||||
}
|
||||
if (other.is_number_unsigned()) return std::cmp_less(get<std::int64_t>(), other.get<std::uint64_t>());
|
||||
return get<std::int64_t>() < other.get<std::int64_t>();
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct ProfileHandler : rapidjson::BaseReaderHandler<rapidjson::UTF8<>, ProfileHandler> {
|
||||
struct Frame { Json* value; std::string key; };
|
||||
Json root;
|
||||
std::vector<Frame> stack;
|
||||
JsonLimits limits;
|
||||
std::size_t events{};
|
||||
|
||||
explicit ProfileHandler(JsonLimits bounds) : limits(bounds) {}
|
||||
void event(std::size_t depth) {
|
||||
if (depth > limits.depth || ++events > limits.events)
|
||||
throw std::runtime_error("Local profile JSON is too complex");
|
||||
}
|
||||
Json& add(Json value) {
|
||||
if (stack.empty()) { root = std::move(value); return root; }
|
||||
auto& frame = stack.back();
|
||||
if (frame.value->is_object()) {
|
||||
auto& slot = (*frame.value)[frame.key]; slot = std::move(value); return slot;
|
||||
}
|
||||
frame.value->push_back(std::move(value));
|
||||
return frame.value->at(frame.value->size() - 1);
|
||||
}
|
||||
bool scalar(Json value) { event(stack.size()); add(std::move(value)); return true; }
|
||||
bool Null() { return scalar(nullptr); }
|
||||
bool Bool(bool value) { return scalar(value); }
|
||||
bool Int(int value) { return scalar(value); }
|
||||
bool Uint(unsigned value) { return scalar(value); }
|
||||
bool Int64(std::int64_t value) { return scalar(value); }
|
||||
bool Uint64(std::uint64_t value) { return scalar(value); }
|
||||
bool Double(double value) { return scalar(value); }
|
||||
bool String(const char* text, rapidjson::SizeType length, bool) {
|
||||
return scalar(std::string(text, length));
|
||||
}
|
||||
bool Key(const char* text, rapidjson::SizeType length, bool) {
|
||||
event(stack.size());
|
||||
auto& frame = stack.back();
|
||||
frame.key.assign(text, length);
|
||||
if (frame.value->contains(frame.key)) throw std::runtime_error("Duplicate local profile JSON key");
|
||||
return true;
|
||||
}
|
||||
bool start(Json value) {
|
||||
event(stack.size());
|
||||
auto& node = add(std::move(value)); stack.push_back({&node, {}}); return true;
|
||||
}
|
||||
bool StartObject() { return start(Json::object()); }
|
||||
bool StartArray() { return start(Json::array()); }
|
||||
bool end() { stack.pop_back(); event(stack.size()); return true; }
|
||||
bool EndObject(rapidjson::SizeType) { return end(); }
|
||||
bool EndArray(rapidjson::SizeType) { return end(); }
|
||||
};
|
||||
|
||||
template<class Writer> bool write_json(Writer& writer, const Json& value) {
|
||||
if (value.is_null()) return writer.Null();
|
||||
if (value.is_boolean()) return writer.Bool(value.get<bool>());
|
||||
if (value.is_number_unsigned()) return writer.Uint64(value.get<std::uint64_t>());
|
||||
if (value.is_number_integer()) return writer.Int64(value.get<std::int64_t>());
|
||||
if (value.is_number_float()) {
|
||||
const auto number = value.get<double>();
|
||||
return std::isfinite(number) ? writer.Double(number) : writer.Null();
|
||||
}
|
||||
if (value.is_string()) {
|
||||
const auto& text = value.string();
|
||||
return writer.String(text.data(), static_cast<rapidjson::SizeType>(text.size()));
|
||||
}
|
||||
if (value.is_object()) {
|
||||
if (!writer.StartObject()) return false;
|
||||
for (const auto& [key, field] : value.items()) {
|
||||
if (!writer.Key(key.data(), static_cast<rapidjson::SizeType>(key.size())) || !write_json(writer, field)) return false;
|
||||
}
|
||||
return writer.EndObject();
|
||||
}
|
||||
if (!writer.StartArray()) return false;
|
||||
for (const auto& item : value) if (!write_json(writer, item)) return false;
|
||||
return writer.EndArray();
|
||||
}
|
||||
}
|
||||
|
||||
Json Json::parse(std::string_view text, JsonLimits limits) {
|
||||
if (text.size() > limits.bytes) throw std::runtime_error("Local profile exceeds size limit");
|
||||
if (text.starts_with("\xef\xbb\xbf")) text.remove_prefix(3);
|
||||
rapidjson::MemoryStream stream(text.empty() ? "" : text.data(), text.size());
|
||||
rapidjson::Reader reader;
|
||||
ProfileHandler handler(limits);
|
||||
constexpr unsigned flags = rapidjson::kParseValidateEncodingFlag | rapidjson::kParseFullPrecisionFlag |
|
||||
rapidjson::kParseIterativeFlag;
|
||||
const auto result = reader.Parse<flags>(stream, handler);
|
||||
if (!result) throw std::runtime_error("Invalid JSON at byte " + std::to_string(result.Offset()) +
|
||||
": " + rapidjson::GetParseError_En(result.Code()));
|
||||
// MemoryStream uses NUL as its sentinel. A literal NUL in a file must not
|
||||
// hide a second document or trailing garbage after an otherwise valid root.
|
||||
if (stream.Tell() != text.size()) throw std::runtime_error("Unexpected data after JSON root");
|
||||
return std::move(handler.root);
|
||||
}
|
||||
|
||||
std::string Json::dump(int indent) const {
|
||||
rapidjson::StringBuffer buffer;
|
||||
bool ok{};
|
||||
if (indent < 0) {
|
||||
rapidjson::Writer<rapidjson::StringBuffer, rapidjson::UTF8<>, rapidjson::UTF8<>,
|
||||
rapidjson::CrtAllocator, rapidjson::kWriteValidateEncodingFlag> writer(buffer);
|
||||
ok = write_json(writer, *this);
|
||||
} else {
|
||||
rapidjson::PrettyWriter<rapidjson::StringBuffer, rapidjson::UTF8<>, rapidjson::UTF8<>,
|
||||
rapidjson::CrtAllocator, rapidjson::kWriteValidateEncodingFlag> writer(buffer);
|
||||
writer.SetIndent(' ', static_cast<unsigned>(indent));
|
||||
ok = write_json(writer, *this);
|
||||
}
|
||||
if (!ok) throw std::runtime_error("Cannot encode JSON string as UTF-8");
|
||||
return {buffer.GetString(), buffer.GetSize()};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <concepts>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <ranges>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
struct JsonLimits {
|
||||
std::size_t bytes = 4 * 1024 * 1024;
|
||||
std::size_t depth = 32;
|
||||
std::size_t events = 16384 * 16;
|
||||
};
|
||||
|
||||
// Owning profile values with deep copies and stable object-member references.
|
||||
// RapidJSON's SAX reader/writers handle JSON syntax in json.cpp; this deliberately
|
||||
// small value model keeps allocator lifetimes out of snapshots and runtime code.
|
||||
class Json {
|
||||
public:
|
||||
using Object = std::map<std::string, Json, std::less<>>;
|
||||
using Array = std::vector<Json>;
|
||||
|
||||
Json() = default;
|
||||
Json(const Json&) = default;
|
||||
Json(Json&&) noexcept = default;
|
||||
// Copy before replacing storage, including assignment from a child value.
|
||||
Json& operator=(Json value) noexcept { data_.swap(value.data_); return *this; }
|
||||
Json(std::nullptr_t) {}
|
||||
Json(bool value) : data_(value) {}
|
||||
template<std::integral T> requires (!std::same_as<T, bool>)
|
||||
Json(T value) {
|
||||
if constexpr (std::is_signed_v<T>) data_ = static_cast<std::int64_t>(value);
|
||||
else data_ = static_cast<std::uint64_t>(value);
|
||||
}
|
||||
template<std::floating_point T> Json(T value) : data_(static_cast<double>(value)) {}
|
||||
Json(const char* value) : data_(std::string(value)) {}
|
||||
Json(std::string value) : data_(std::move(value)) {}
|
||||
Json(std::string_view value) : data_(std::string(value)) {}
|
||||
Json(std::initializer_list<Json> values);
|
||||
template<std::ranges::input_range R>
|
||||
requires (!std::convertible_to<R, std::string_view> && !std::same_as<R, Json>)
|
||||
Json(const R& values) : data_(Array{}) {
|
||||
for (const auto& value : values) std::get<Array>(data_).emplace_back(value);
|
||||
}
|
||||
|
||||
static Json object() { Json value; value.data_ = Object{}; return value; }
|
||||
static Json array(std::initializer_list<Json> values = {}) {
|
||||
Json value; value.data_ = Array(values); return value;
|
||||
}
|
||||
static Json parse(std::string_view text, JsonLimits limits = {});
|
||||
template<std::input_iterator It> static Json parse(It first, It last) {
|
||||
return parse(std::string(first, last));
|
||||
}
|
||||
std::string dump(int indent = -1) const;
|
||||
|
||||
bool is_null() const { return std::holds_alternative<std::monostate>(data_); }
|
||||
bool is_boolean() const { return std::holds_alternative<bool>(data_); }
|
||||
bool is_number_unsigned() const { return std::holds_alternative<std::uint64_t>(data_); }
|
||||
bool is_number_integer() const { return is_number_unsigned() || std::holds_alternative<std::int64_t>(data_); }
|
||||
bool is_number_float() const { return std::holds_alternative<double>(data_); }
|
||||
bool is_number() const { return is_number_integer() || is_number_float(); }
|
||||
bool is_string() const { return std::holds_alternative<std::string>(data_); }
|
||||
bool is_array() const { return std::holds_alternative<Array>(data_); }
|
||||
bool is_object() const { return std::holds_alternative<Object>(data_); }
|
||||
std::size_t size() const {
|
||||
if (is_object()) return std::get<Object>(data_).size();
|
||||
if (is_array()) return std::get<Array>(data_).size();
|
||||
return is_null() ? 0 : 1;
|
||||
}
|
||||
bool empty() const { return size() == 0; }
|
||||
const std::string& string() const { return std::get<std::string>(data_); }
|
||||
template<class T> T get() const {
|
||||
if constexpr (std::same_as<T, Json>) return *this;
|
||||
else if constexpr (std::same_as<T, std::string>) return string();
|
||||
else if constexpr (std::same_as<T, bool>) return std::get<bool>(data_);
|
||||
else if constexpr (std::is_arithmetic_v<T>) {
|
||||
if (is_number_unsigned()) return static_cast<T>(std::get<std::uint64_t>(data_));
|
||||
if (std::holds_alternative<std::int64_t>(data_)) return static_cast<T>(std::get<std::int64_t>(data_));
|
||||
if (is_number_float()) return static_cast<T>(std::get<double>(data_));
|
||||
throw std::runtime_error("JSON value must be numeric");
|
||||
} else static_assert(sizeof(T) == 0, "Unsupported JSON conversion");
|
||||
}
|
||||
bool contains(std::string_view key) const {
|
||||
return is_object() && std::get<Object>(data_).contains(key);
|
||||
}
|
||||
const Json& at(std::string_view key) const {
|
||||
const auto& fields = std::get<Object>(data_);
|
||||
const auto found = fields.find(key);
|
||||
if (found == fields.end()) throw std::out_of_range("Missing JSON field: " + std::string(key));
|
||||
return found->second;
|
||||
}
|
||||
Json& at(std::string_view key) { return const_cast<Json&>(std::as_const(*this).at(key)); }
|
||||
const Json& at(std::size_t index) const { return std::get<Array>(data_).at(index); }
|
||||
Json& at(std::size_t index) { return std::get<Array>(data_).at(index); }
|
||||
Json& operator[](std::string_view key) {
|
||||
if (is_null()) data_ = Object{};
|
||||
return std::get<Object>(data_)[std::string(key)];
|
||||
}
|
||||
const Json& operator[](std::string_view key) const { return at(key); }
|
||||
Json& operator[](std::size_t index) { return at(index); }
|
||||
const Json& operator[](std::size_t index) const { return at(index); }
|
||||
template<class T> T value(std::string_view key, const T& fallback) const {
|
||||
if (!is_object()) throw std::runtime_error("JSON value must be an object");
|
||||
return contains(key) ? at(key).get<T>() : fallback;
|
||||
}
|
||||
std::string value(std::string_view key, const char* fallback) const {
|
||||
return value(key, std::string(fallback));
|
||||
}
|
||||
Object& items() { return std::get<Object>(data_); }
|
||||
const Object& items() const { return std::get<Object>(data_); }
|
||||
std::size_t erase(std::string_view key) { return items().erase(std::string(key)); }
|
||||
void push_back(Json value) {
|
||||
if (is_null()) data_ = Array{};
|
||||
std::get<Array>(data_).push_back(std::move(value));
|
||||
}
|
||||
|
||||
template<bool Const> class Iterator {
|
||||
using Owner = std::conditional_t<Const, const Json, Json>;
|
||||
using Member = std::conditional_t<Const, Object::const_iterator, Object::iterator>;
|
||||
Owner* owner_{};
|
||||
Member member_{};
|
||||
std::size_t index_{};
|
||||
friend class Json;
|
||||
Iterator(Owner* owner, bool end) : owner_(owner), index_(end ? owner->size() : 0) {
|
||||
if (owner->is_object()) member_ = end ? owner->items().end() : owner->items().begin();
|
||||
}
|
||||
public:
|
||||
using value_type = Json;
|
||||
using difference_type = std::ptrdiff_t;
|
||||
using reference = Owner&;
|
||||
using pointer = Owner*;
|
||||
using iterator_category = std::forward_iterator_tag;
|
||||
Iterator() = default;
|
||||
reference operator*() const { return owner_->is_object() ? member_->second : owner_->at(index_); }
|
||||
pointer operator->() const { return &**this; }
|
||||
Iterator& operator++() { if (owner_->is_object()) ++member_; else ++index_; return *this; }
|
||||
Iterator operator++(int) { auto old = *this; ++*this; return old; }
|
||||
bool operator==(const Iterator& other) const {
|
||||
if (owner_ != other.owner_) return false;
|
||||
return owner_ && owner_->is_object() ? member_ == other.member_ : index_ == other.index_;
|
||||
}
|
||||
};
|
||||
using iterator = Iterator<false>;
|
||||
using const_iterator = Iterator<true>;
|
||||
iterator begin() { return iterator(this, false); }
|
||||
iterator end() { return iterator(this, true); }
|
||||
const_iterator begin() const { return const_iterator(this, false); }
|
||||
const_iterator end() const { return const_iterator(this, true); }
|
||||
iterator find(std::string_view key) {
|
||||
auto result = end(); if (is_object()) result.member_ = items().find(key); return result;
|
||||
}
|
||||
const_iterator find(std::string_view key) const {
|
||||
auto result = end(); if (is_object()) result.member_ = items().find(key); return result;
|
||||
}
|
||||
bool operator==(const Json& other) const;
|
||||
bool operator<(const Json& other) const;
|
||||
bool operator>(const Json& other) const { return other < *this; }
|
||||
bool operator<=(const Json& other) const { return *this == other || *this < other; }
|
||||
bool operator>=(const Json& other) const { return *this == other || other < *this; }
|
||||
|
||||
private:
|
||||
std::variant<std::monostate, bool, std::int64_t, std::uint64_t, double, std::string, Array, Object> data_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "logging_internal.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
void startup_banner() noexcept {
|
||||
detail::PreserveError preserve;
|
||||
static std::atomic_flag shown = ATOMIC_FLAG_INIT;
|
||||
if (shown.test_and_set()) return;
|
||||
write(Level::info, Channel::runtime, R"banner( ____ ____ _ _
|
||||
| _ \ ___ / ___|| | ____ _| |_ ___
|
||||
| |_) / _ \\___ \| |/ / _` | __/ _ \
|
||||
| _ < __/ ___) | < (_| | || __/
|
||||
|_| \_\___||____/|_|\_\__,_|\__\___|
|
||||
o================o)banner");
|
||||
log(Level::info, Channel::runtime, "Skate SDK | development build | Windows x64 | MSVC {} | {} {}",
|
||||
_MSC_VER, __DATE__, __TIME__);
|
||||
try {
|
||||
const auto config = status();
|
||||
write(Level::info, Channel::runtime, L"Log: " + (config.directory / L"ReSkate.log").wstring());
|
||||
log(Level::info, Channel::runtime, "Log level: {} | Windows console: {}",
|
||||
name(config.level), config.external_console ? "on" : "off");
|
||||
write(Level::info, Channel::runtime,
|
||||
"Sources: C=client, S=server, U=UI, E=engine, F=filesystem, M=materials/rendering, A=audio; SDK=tools/runtime.");
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "logging_internal.h"
|
||||
#include "Engine/Core/Debug/backtrace.h"
|
||||
#include <array>
|
||||
|
||||
namespace dingosdk::logging::detail {
|
||||
namespace {
|
||||
std::array<wchar_t, 32768> report_path{};
|
||||
LPTOP_LEVEL_EXCEPTION_FILTER previous{};
|
||||
volatile LONG reporting{};
|
||||
bool installed{};
|
||||
// Last-chance reporting deliberately avoids the logger, heap, CRT formatting,
|
||||
// symbol loader and their locks. Leave Windows/game exception handling intact.
|
||||
LONG WINAPI unhandled(EXCEPTION_POINTERS* exception) {
|
||||
if (InterlockedCompareExchange(&reporting, 1, 0) == 0 && exception && exception->ExceptionRecord && exception->ContextRecord) {
|
||||
char text[2048]{};
|
||||
std::size_t used = 0;
|
||||
const auto append = [&](const char* value) { while (*value && used + 1 < sizeof(text)) text[used++] = *value++; };
|
||||
const auto hex = [&](const char* label, unsigned long long value) {
|
||||
append(label); append("=0x");
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
for (int shift = 60; shift >= 0; shift -= 4) if (used + 1 < sizeof(text)) text[used++] = digits[(value >> shift) & 15];
|
||||
append("\r\n");
|
||||
};
|
||||
append("\r\n[CRITICAL] ReSkate unhandled exception\r\n");
|
||||
hex("process", GetCurrentProcessId()); hex("thread", GetCurrentThreadId());
|
||||
hex("exception", exception->ExceptionRecord->ExceptionCode);
|
||||
hex("address", reinterpret_cast<ULONG_PTR>(exception->ExceptionRecord->ExceptionAddress));
|
||||
hex("rip", exception->ContextRecord->Rip); hex("rsp", exception->ContextRecord->Rsp);
|
||||
hex("rbp", exception->ContextRecord->Rbp);
|
||||
if (exception->ExceptionRecord->NumberParameters >= 2 && exception->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) {
|
||||
hex("access_type", exception->ExceptionRecord->ExceptionInformation[0]);
|
||||
hex("access_address", exception->ExceptionRecord->ExceptionInformation[1]);
|
||||
}
|
||||
const auto file = CreateFileW(report_path.data(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file != INVALID_HANDLE_VALUE) {
|
||||
DWORD written{}; WriteFile(file, text, static_cast<DWORD>(used), &written, nullptr);
|
||||
FlushFileBuffers(file); CloseHandle(file);
|
||||
}
|
||||
}
|
||||
backtrace::capture(exception);
|
||||
return previous ? previous(exception) : EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
}
|
||||
void install_crash_report(const std::filesystem::path& directory) noexcept {
|
||||
try {
|
||||
if (installed) return;
|
||||
const auto path = (directory / L"ReSkate.log").wstring();
|
||||
if (path.size() >= report_path.size()) return;
|
||||
std::copy(path.begin(), path.end(), report_path.begin()); report_path[path.size()] = 0;
|
||||
reporting = 0;
|
||||
previous = SetUnhandledExceptionFilter(unhandled); installed = true;
|
||||
} catch (...) {}
|
||||
}
|
||||
void remove_crash_report() noexcept {
|
||||
if (!installed) return;
|
||||
const auto current = SetUnhandledExceptionFilter(previous);
|
||||
if (current != unhandled) SetUnhandledExceptionFilter(current);
|
||||
installed = false;
|
||||
backtrace::stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "logging_internal.h"
|
||||
#include <rapidjson/document.h>
|
||||
#include <rapidjson/stringbuffer.h>
|
||||
#include <rapidjson/writer.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
void event(Channel channel, std::string_view json, Level level) noexcept {
|
||||
event(default_context(channel), channel, json, level);
|
||||
}
|
||||
void event(Context context, Channel channel, std::string_view json, Level level) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try {
|
||||
if (json.empty()) return;
|
||||
if (json.size() > 256 * 1024) {
|
||||
write(Level::warning, context, channel, "Diagnostic event exceeded the 256 KiB limit."); return;
|
||||
}
|
||||
rapidjson::Document document;
|
||||
document.Parse<rapidjson::kParseValidateEncodingFlag | rapidjson::kParseIterativeFlag>(json.data(), json.size());
|
||||
if (document.HasParseError() || !document.IsObject()) {
|
||||
write(Level::warning, context, channel, "Malformed diagnostic event was discarded."); return;
|
||||
}
|
||||
const auto event = document.FindMember("event");
|
||||
const std::string_view id = event != document.MemberEnd() && event->value.IsString()
|
||||
? std::string_view(event->value.GetString(), event->value.GetStringLength()) : "observation";
|
||||
// Legacy native observers use structured events. Preserve their severity
|
||||
// during migration, including errors produced through generic callbacks.
|
||||
if (id.ends_with("_failed") || id == "bootstrap_failed") level = std::max(level, Level::error);
|
||||
else if (id.ends_with("_rejected") || id.ends_with("_mismatch")) level = std::max(level, Level::warning);
|
||||
const std::pair<std::string_view, std::string_view> activities[]{
|
||||
{"local_profile_initialized", "Local profile services initialized"},
|
||||
{"local_profile_hydrated", "Saved profile applied to the game"},
|
||||
{"local_profile_mission_saved", "Mission progress saved"},
|
||||
{"local_location_travel_request", "Travel requested"},
|
||||
{"local_object_browser_ready", "Object browser opened"},
|
||||
{"local_object_categories_published", "Object browser categories populated"},
|
||||
{"controller_binding_saved", "Controller binding saved"},
|
||||
{"local_player_card_saved", "Player card saved"}
|
||||
};
|
||||
const auto activity = std::find_if(std::begin(activities), std::end(activities),
|
||||
[&](const auto& entry) { return entry.first == id; });
|
||||
if (activity != std::end(activities)) level = std::max(level, Level::info);
|
||||
if (id == "launcher_readiness") {
|
||||
const auto ready = document.FindMember("ready");
|
||||
if (ready != document.MemberEnd() && ready->value.IsBool() && !ready->value.GetBool())
|
||||
level = std::max(level, Level::error);
|
||||
}
|
||||
if (id == "local_profile_event_saved") {
|
||||
// Aggregate automatic saves: first success, then at most one update
|
||||
// per 30 seconds when saves continue. No timer or background thread.
|
||||
static std::atomic<ULONGLONG> next{};
|
||||
static std::atomic<std::uint64_t> saves{};
|
||||
++saves;
|
||||
const auto now = GetTickCount64();
|
||||
auto due = next.load();
|
||||
if (now >= due && next.compare_exchange_strong(due, now + 30000))
|
||||
log(Level::info, context, Channel::profile, "Profile changes saved ({} updates).", saves.exchange(0));
|
||||
}
|
||||
const auto error = document.FindMember("error");
|
||||
if (error != document.MemberEnd() && ((error->value.IsBool() && error->value.GetBool()) ||
|
||||
(error->value.IsString() && error->value.GetStringLength()))) level = std::max(level, Level::error);
|
||||
if (!enabled(level)) return;
|
||||
std::string message(activity != std::end(activities) ? activity->second : id);
|
||||
std::replace(message.begin(), message.end(), '_', ' ');
|
||||
if (!message.empty() && message[0] >= 'a' && message[0] <= 'z') message[0] -= 'a' - 'A';
|
||||
bool first = true;
|
||||
for (auto member = document.MemberBegin(); member != document.MemberEnd(); ++member) {
|
||||
if (std::string_view(member->name.GetString(), member->name.GetStringLength()) == "event") continue;
|
||||
message += first ? ": " : ", "; first = false;
|
||||
message.append(member->name.GetString(), member->name.GetStringLength()); message += '=';
|
||||
const auto& value = member->value;
|
||||
if (value.IsString()) message.append(value.GetString(), std::min<rapidjson::SizeType>(value.GetStringLength(), 512));
|
||||
else if (value.IsArray()) message += std::format("[{} items]", value.Size());
|
||||
else if (value.IsObject()) message += std::format("[{} fields]", value.MemberCount());
|
||||
else {
|
||||
rapidjson::StringBuffer buffer; rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
|
||||
value.Accept(writer); message.append(buffer.GetString(), buffer.GetSize());
|
||||
}
|
||||
if (message.size() > 8000) { message += "... [truncated]"; break; }
|
||||
}
|
||||
write(level, context, channel, message);
|
||||
log(Level::trace, context, channel, "Native event: {}", json);
|
||||
} catch (...) { write(Level::error, context, channel, "Could not record a diagnostic event."); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
#include "logging_internal.h"
|
||||
#include "Engine/Core/Debug/backtrace.h"
|
||||
#include "Engine/Core/Console/console_core.h"
|
||||
#include <spdlog/logger.h>
|
||||
#include <spdlog/sinks/base_sink.h>
|
||||
#include <spdlog/sinks/msvc_sink.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
namespace {
|
||||
spdlog::level::level_enum native_level(Level level) noexcept {
|
||||
switch (level) {
|
||||
case Level::trace: return spdlog::level::trace;
|
||||
case Level::debug: return spdlog::level::debug;
|
||||
case Level::warning: return spdlog::level::warn;
|
||||
case Level::error: return spdlog::level::err;
|
||||
case Level::critical: return spdlog::level::critical;
|
||||
case Level::off: return spdlog::level::off;
|
||||
default: return spdlog::level::info;
|
||||
}
|
||||
}
|
||||
// FILE_APPEND_DATA chooses EOF for each WriteFile, including writes from the
|
||||
// launcher while the runtime is initializing. No independent buffered offsets.
|
||||
class SharedFile final : public spdlog::sinks::base_sink<std::mutex> {
|
||||
public:
|
||||
SharedFile(const std::filesystem::path& path, bool truncate) {
|
||||
constexpr DWORD sharing = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
|
||||
if (truncate) {
|
||||
const auto reset = CreateFileW(path.c_str(), GENERIC_WRITE, sharing, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (reset == INVALID_HANDLE_VALUE) spdlog::throw_spdlog_ex("Cannot reset ReSkate.log", GetLastError());
|
||||
CloseHandle(reset);
|
||||
}
|
||||
file_ = CreateFileW(path.c_str(), FILE_APPEND_DATA, sharing, nullptr,
|
||||
OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file_ == INVALID_HANDLE_VALUE) spdlog::throw_spdlog_ex("Cannot open ReSkate.log", GetLastError());
|
||||
}
|
||||
~SharedFile() override { if (file_ != INVALID_HANDLE_VALUE) CloseHandle(file_); }
|
||||
private:
|
||||
void sink_it_(const spdlog::details::log_msg& message) override {
|
||||
spdlog::memory_buf_t formatted;
|
||||
formatter_->format(message, formatted);
|
||||
DWORD written{};
|
||||
if (!WriteFile(file_, formatted.data(), static_cast<DWORD>(formatted.size()), &written, nullptr))
|
||||
spdlog::throw_spdlog_ex("Cannot write ReSkate.log", GetLastError());
|
||||
if (written != formatted.size()) spdlog::throw_spdlog_ex("Incomplete write to ReSkate.log");
|
||||
}
|
||||
// WriteFile already handed each message to Windows. Avoid durable disk
|
||||
// flushes in game hooks; crashes perform a final best-effort flush directly.
|
||||
void flush_() override {}
|
||||
HANDLE file_{INVALID_HANDLE_VALUE};
|
||||
};
|
||||
class WindowConsole final : public spdlog::sinks::base_sink<std::mutex> {
|
||||
public:
|
||||
WindowConsole() {
|
||||
handle_ = CreateFileW(L"CONOUT$", GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
if (handle_ == INVALID_HANDLE_VALUE && AllocConsole()) {
|
||||
owned_ = true;
|
||||
SetConsoleTitleW(L"ReSkate Console");
|
||||
// Text selection in a classic console must not pause game hooks.
|
||||
const auto input = GetStdHandle(STD_INPUT_HANDLE);
|
||||
DWORD mode{};
|
||||
if (GetConsoleMode(input, &mode))
|
||||
SetConsoleMode(input, (mode | ENABLE_EXTENDED_FLAGS) & ~ENABLE_QUICK_EDIT_MODE);
|
||||
handle_ = CreateFileW(L"CONOUT$", GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
}
|
||||
CONSOLE_SCREEN_BUFFER_INFO info{};
|
||||
if (handle_ != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(handle_, &info)) original_ = info.wAttributes;
|
||||
}
|
||||
~WindowConsole() override {
|
||||
if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_);
|
||||
if (owned_) FreeConsole();
|
||||
}
|
||||
bool available() const noexcept { return handle_ != INVALID_HANDLE_VALUE; }
|
||||
private:
|
||||
void sink_it_(const spdlog::details::log_msg& message) override {
|
||||
if (!available()) return;
|
||||
spdlog::memory_buf_t formatted;
|
||||
formatter_->format(message, formatted);
|
||||
const auto count = MultiByteToWideChar(CP_UTF8, 0, formatted.data(), static_cast<int>(formatted.size()), nullptr, 0);
|
||||
if (count <= 0) return;
|
||||
std::wstring wide(static_cast<std::size_t>(count), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, formatted.data(), static_cast<int>(formatted.size()), wide.data(), count);
|
||||
const WORD color = message.level >= spdlog::level::err ? FOREGROUND_RED | FOREGROUND_INTENSITY
|
||||
: message.level == spdlog::level::warn ? FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_INTENSITY
|
||||
: message.logger_name == "Native(S)" ? FOREGROUND_BLUE | FOREGROUND_INTENSITY
|
||||
: message.logger_name == "Native(M)" || message.logger_name == "Native(U)" ? FOREGROUND_RED | FOREGROUND_BLUE | FOREGROUND_INTENSITY
|
||||
: message.logger_name == "Native(F)" ? FOREGROUND_GREEN | FOREGROUND_BLUE | FOREGROUND_INTENSITY
|
||||
: message.logger_name == "Native(C)" ? FOREGROUND_GREEN | FOREGROUND_INTENSITY
|
||||
: message.logger_name == "Native(A)" ? FOREGROUND_RED | FOREGROUND_GREEN
|
||||
: original_;
|
||||
SetConsoleTextAttribute(handle_, color);
|
||||
DWORD written{}; WriteConsoleW(handle_, wide.data(), static_cast<DWORD>(wide.size()), &written, nullptr);
|
||||
SetConsoleTextAttribute(handle_, original_);
|
||||
}
|
||||
void flush_() override {}
|
||||
HANDLE handle_{INVALID_HANDLE_VALUE};
|
||||
WORD original_{FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE};
|
||||
bool owned_{};
|
||||
};
|
||||
struct RepeatedMessage {
|
||||
Level level{};
|
||||
Channel channel{};
|
||||
Context context{};
|
||||
std::string text;
|
||||
ULONGLONG last{};
|
||||
std::uint64_t suppressed{};
|
||||
};
|
||||
struct State {
|
||||
std::mutex mutex;
|
||||
std::atomic<Level> level{Level::info};
|
||||
std::atomic<bool> file_output{};
|
||||
bool initialized{}, console{};
|
||||
Options options;
|
||||
std::vector<RepeatedMessage> repeats;
|
||||
std::array<std::shared_ptr<spdlog::logger>, context_count> loggers;
|
||||
ConsoleLogBuffer history{{2048, 2 * 1024 * 1024, 8300}};
|
||||
};
|
||||
State& state() { static auto* value = new State; return *value; }
|
||||
void sink_error(const std::string& error) noexcept {
|
||||
state().file_output.store(false);
|
||||
OutputDebugStringA("ReSkate logging sink failed: ");
|
||||
OutputDebugStringA(error.c_str());
|
||||
OutputDebugStringA("\n");
|
||||
}
|
||||
// State::mutex serializes history and all sinks so every view has the same order.
|
||||
void emit_locked(State& s, Level level, Context context, Channel channel, std::string_view text) {
|
||||
const auto index = static_cast<std::size_t>(context);
|
||||
s.history.append(text, level, channel, context);
|
||||
std::size_t start = 0;
|
||||
while (start < text.size()) {
|
||||
const auto end = text.find('\n', start);
|
||||
const auto line = text.substr(start, end == std::string::npos ? text.size() - start : end - start);
|
||||
const auto payload = std::format("[{}] [{}] {}", name(channel), name(level), line);
|
||||
if (s.loggers[index]) s.loggers[index]->log(native_level(level), payload);
|
||||
else OutputDebugStringA(std::format("{}: {}\n", name(context), payload).c_str());
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
void report_repeats(State& s, RepeatedMessage& repeat) {
|
||||
if (!repeat.suppressed) return;
|
||||
if (enabled(repeat.level)) emit_locked(s, repeat.level, repeat.context, repeat.channel,
|
||||
std::format("Repeated {} more times: {}", repeat.suppressed, repeat.text));
|
||||
repeat.suppressed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool initialize(const Options& options) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try {
|
||||
auto& s = state();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (s.initialized) return s.file_output.load();
|
||||
s.options = options;
|
||||
s.level.store(options.level);
|
||||
std::vector<spdlog::sink_ptr> sinks;
|
||||
auto debugger = std::make_shared<spdlog::sinks::msvc_sink_mt>();
|
||||
sinks.push_back(debugger);
|
||||
if (options.external_console) {
|
||||
auto console = std::make_shared<WindowConsole>();
|
||||
s.console = console->available();
|
||||
if (s.console) sinks.push_back(console);
|
||||
}
|
||||
try {
|
||||
std::filesystem::create_directories(options.directory);
|
||||
sinks.push_back(std::make_shared<SharedFile>(options.directory / L"ReSkate.log", options.truncate_file));
|
||||
s.file_output.store(true);
|
||||
} catch (const std::exception& error) { sink_error(error.what()); }
|
||||
for (std::size_t i = 0; i < context_count; ++i) {
|
||||
auto logger = std::make_shared<spdlog::logger>(std::string(context_names[i]), sinks.begin(), sinks.end());
|
||||
logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%P:%t] %n: %v");
|
||||
logger->set_level(spdlog::level::trace);
|
||||
// Sinks write synchronously without a logging worker. Startup messages
|
||||
// remain readable while diagnosing a hang.
|
||||
logger->flush_on(spdlog::level::trace);
|
||||
logger->set_error_handler(sink_error);
|
||||
s.loggers[i] = std::move(logger);
|
||||
}
|
||||
s.initialized = true;
|
||||
detail::install_crash_report(options.directory);
|
||||
const auto reporter = backtrace::start(options.directory);
|
||||
emit_locked(s, reporter == backtrace::StartResult::failed ? Level::warning : Level::info,
|
||||
Context::sdk, Channel::runtime,
|
||||
reporter == backtrace::StartResult::ready ? "Backtrace crash reporting enabled."
|
||||
: reporter == backtrace::StartResult::disabled ? "Backtrace crash reporting disabled."
|
||||
: "Backtrace crash reporter could not start; local crash logging remains active.");
|
||||
return s.file_output.load();
|
||||
} catch (...) {
|
||||
OutputDebugStringA("ReSkate logging initialization failed; debugger output remains available.\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool enabled(Level level) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try { const auto minimum = state().level.load(); return level != Level::off && minimum != Level::off && level >= minimum; }
|
||||
catch (...) { return level >= Level::warning && level != Level::off; }
|
||||
}
|
||||
void set_level(Level level) noexcept { detail::PreserveError preserve; try { state().level.store(level); } catch (...) {} }
|
||||
void write(Level level, Channel channel, std::string_view message) noexcept {
|
||||
write(level, default_context(channel), channel, message);
|
||||
}
|
||||
void write(Level level, Context context, Channel channel, std::string_view message) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try {
|
||||
if (level == Level::off || (!enabled(level) && channel != Channel::command) || message.empty()) return;
|
||||
auto text = detail::clean(message);
|
||||
if (text.empty()) return;
|
||||
auto& s = state();
|
||||
std::lock_guard lock(s.mutex);
|
||||
const auto index = static_cast<std::size_t>(channel);
|
||||
if (index >= channel_count) channel = Channel::runtime;
|
||||
if (static_cast<std::size_t>(context) >= context_count) context = Context::sdk;
|
||||
const auto now = GetTickCount64();
|
||||
for (auto& repeat : s.repeats)
|
||||
if (now - repeat.last >= 5000) report_repeats(s, repeat);
|
||||
if (channel != Channel::command && level != Level::critical) {
|
||||
auto found = std::find_if(s.repeats.begin(), s.repeats.end(), [&](const auto& entry) {
|
||||
return entry.level == level && entry.channel == channel && entry.context == context && entry.text == text;
|
||||
});
|
||||
if (found != s.repeats.end()) {
|
||||
if (now - found->last < 5000) { ++found->suppressed; return; }
|
||||
found->last = now;
|
||||
} else {
|
||||
if (s.repeats.size() == 128) {
|
||||
auto oldest = std::min_element(s.repeats.begin(), s.repeats.end(),
|
||||
[](const auto& left, const auto& right) { return left.last < right.last; });
|
||||
report_repeats(s, *oldest);
|
||||
*oldest = {level, channel, context, text, now, 0};
|
||||
} else s.repeats.push_back({level, channel, context, text, now, 0});
|
||||
}
|
||||
}
|
||||
emit_locked(s, level, context, channel, text);
|
||||
} catch (...) { OutputDebugStringA("ReSkate: a log message could not be recorded.\n"); }
|
||||
}
|
||||
void write(Level level, Channel channel, std::wstring_view message) noexcept {
|
||||
write(level, default_context(channel), channel, message);
|
||||
}
|
||||
void write(Level level, Context context, Channel channel, std::wstring_view message) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try { write(level, context, channel, detail::utf8(message)); } catch (...) {}
|
||||
}
|
||||
void vprintf(Level level, Channel channel, const char* format, va_list args) noexcept {
|
||||
vprintf(level, default_context(channel), channel, format, args);
|
||||
}
|
||||
void vprintf(Level level, Context context, Channel channel, const char* format, va_list args) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
if (!format || level == Level::off || (!enabled(level) && channel != Channel::command)) return;
|
||||
std::array<char, 8192> message{};
|
||||
_vsnprintf_s(message.data(), message.size(), _TRUNCATE, format, args);
|
||||
write(level, context, channel, message.data());
|
||||
}
|
||||
void printf(Level level, Channel channel, const char* format, ...) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
va_list args; va_start(args, format); vprintf(level, channel, format, args); va_end(args);
|
||||
}
|
||||
void printf(Level level, Context context, Channel channel, const char* format, ...) noexcept {
|
||||
detail::PreserveError preserve;
|
||||
va_list args; va_start(args, format); vprintf(level, context, channel, format, args); va_end(args);
|
||||
}
|
||||
Status status() {
|
||||
detail::PreserveError preserve;
|
||||
auto& s = state(); std::lock_guard lock(s.mutex);
|
||||
return {s.options.directory, s.level.load(), s.initialized, s.file_output.load(), s.console};
|
||||
}
|
||||
std::vector<ConsoleLogLine> snapshot_after(std::uint64_t sequence) { return state().history.snapshot_after(sequence); }
|
||||
std::uint64_t latest_sequence() noexcept { try { return state().history.latest_sequence(); } catch (...) { return 0; } }
|
||||
void flush() noexcept {
|
||||
detail::PreserveError preserve;
|
||||
try {
|
||||
auto& s = state(); std::lock_guard lock(s.mutex);
|
||||
for (auto& repeat : s.repeats) report_repeats(s, repeat);
|
||||
for (const auto& logger : s.loggers) if (logger) logger->flush();
|
||||
} catch (...) {}
|
||||
}
|
||||
void shutdown() noexcept {
|
||||
detail::PreserveError preserve;
|
||||
flush();
|
||||
try {
|
||||
auto& s = state(); std::lock_guard lock(s.mutex);
|
||||
detail::remove_crash_report();
|
||||
for (auto& logger : s.loggers) logger.reset();
|
||||
s.repeats.clear();
|
||||
s.initialized = false; s.file_output = false; s.console = false;
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
#include "types.h"
|
||||
#include "Engine/Core/Console/console_entry.h"
|
||||
#include <Windows.h>
|
||||
#include <cstdarg>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
struct Options {
|
||||
std::filesystem::path directory;
|
||||
Level level{Level::info};
|
||||
bool external_console{};
|
||||
// The launcher resets the file before starting the child; the runtime appends.
|
||||
bool truncate_file{};
|
||||
};
|
||||
struct Status {
|
||||
std::filesystem::path directory;
|
||||
Level level{Level::info};
|
||||
bool initialized{}, file_output{}, external_console{};
|
||||
};
|
||||
// Initialize outside DllMain and before native hooks. File failures are nonfatal;
|
||||
// the bounded in-game feed and attached debugger remain usable.
|
||||
bool initialize(const Options& options) noexcept;
|
||||
Options options_from_environment();
|
||||
std::filesystem::path log_directory(const std::filesystem::path& game_directory);
|
||||
Status status();
|
||||
bool enabled(Level level) noexcept;
|
||||
void set_level(Level level) noexcept;
|
||||
void startup_banner() noexcept;
|
||||
void flush() noexcept;
|
||||
// Executables call shutdown at normal exit. The pinned runtime lives until
|
||||
// process teardown; do not take logging locks or join threads from DllMain.
|
||||
void shutdown() noexcept;
|
||||
void write(Level level, Context context, Channel channel, std::string_view message) noexcept;
|
||||
void write(Level level, Context context, Channel channel, std::wstring_view message) noexcept;
|
||||
void vprintf(Level level, Context context, Channel channel, const char* format, va_list args) noexcept;
|
||||
void printf(Level level, Context context, Channel channel, const char* format, ...) noexcept;
|
||||
void write(Level level, Channel channel, std::string_view message) noexcept;
|
||||
void write(Level level, Channel channel, std::wstring_view message) noexcept;
|
||||
void vprintf(Level level, Channel channel, const char* format, va_list args) noexcept;
|
||||
void printf(Level level, Channel channel, const char* format, ...) noexcept;
|
||||
template<class... Args>
|
||||
void log(Level level, Context context, Channel channel, std::format_string<Args...> format, Args&&... args) noexcept {
|
||||
const auto saved = GetLastError();
|
||||
try {
|
||||
if (enabled(level) || (channel == Channel::command && level != Level::off)) write(level, context, channel, std::format(format, std::forward<Args>(args)...));
|
||||
} catch (...) { write(Level::error, Channel::runtime, "Could not format a log message."); }
|
||||
SetLastError(saved);
|
||||
}
|
||||
template<class... Args>
|
||||
void log(Level level, Channel channel, std::format_string<Args...> format, Args&&... args) noexcept {
|
||||
log(level, default_context(channel), channel, format, std::forward<Args>(args)...);
|
||||
}
|
||||
// Structured native observations retain their fields. Human-readable output goes
|
||||
// through the same sinks as ordinary messages; trace level includes raw payloads.
|
||||
void event(Channel channel, std::string_view json, Level level = Level::debug) noexcept;
|
||||
void event(Context context, Channel channel, std::string_view json, Level level = Level::debug) noexcept;
|
||||
std::vector<ConsoleLogLine> snapshot_after(std::uint64_t sequence);
|
||||
std::uint64_t latest_sequence() noexcept;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include "logging.h"
|
||||
namespace dingosdk::logging::detail {
|
||||
struct PreserveError {
|
||||
DWORD saved{GetLastError()};
|
||||
~PreserveError() { SetLastError(saved); }
|
||||
};
|
||||
std::string utf8(std::wstring_view text);
|
||||
std::string clean(std::string_view text);
|
||||
void install_crash_report(const std::filesystem::path& directory) noexcept;
|
||||
void remove_crash_report() noexcept;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "logging_internal.h"
|
||||
#include <ShlObj.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
namespace {
|
||||
std::wstring environment(const wchar_t* name) {
|
||||
std::array<wchar_t, 32768> value{};
|
||||
const auto count = GetEnvironmentVariableW(name, value.data(), static_cast<DWORD>(value.size()));
|
||||
return count && count < value.size() ? std::wstring(value.data(), count) : std::wstring{};
|
||||
}
|
||||
}
|
||||
std::filesystem::path log_directory(const std::filesystem::path& game_directory) {
|
||||
detail::PreserveError preserve;
|
||||
const auto attempt = [&](const std::filesystem::path& path) -> std::filesystem::path {
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path, error);
|
||||
if (error) return {};
|
||||
const auto probe = CreateFileW((path / std::format(L".write-check-{}", GetCurrentProcessId())).c_str(), GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, CREATE_NEW,
|
||||
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
|
||||
if (probe == INVALID_HANDLE_VALUE) return {};
|
||||
CloseHandle(probe);
|
||||
return path;
|
||||
};
|
||||
if (auto path = attempt(game_directory / L"logs"); !path.empty()) return path;
|
||||
PWSTR local{};
|
||||
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DONT_VERIFY, nullptr, &local))) {
|
||||
const auto root = std::filesystem::path(local) / L"ReSkate" / L"logs";
|
||||
CoTaskMemFree(local);
|
||||
if (auto path = attempt(root); !path.empty()) return path;
|
||||
}
|
||||
throw std::runtime_error("Cannot create a log directory beside the game or in Local AppData");
|
||||
}
|
||||
Options options_from_environment() {
|
||||
detail::PreserveError preserve;
|
||||
Options options;
|
||||
options.directory = environment(L"RESKATE_LOG_DIRECTORY");
|
||||
if (options.directory.empty()) {
|
||||
options.truncate_file = true;
|
||||
std::array<wchar_t, 32768> module{};
|
||||
const auto count = GetModuleFileNameW(nullptr, module.data(), static_cast<DWORD>(module.size()));
|
||||
if (!count || count >= module.size()) throw std::runtime_error("Cannot resolve log directory");
|
||||
options.directory = log_directory(std::filesystem::path(module.data()).parent_path());
|
||||
}
|
||||
if (const auto level = parse_level(detail::utf8(environment(L"RESKATE_LOG_LEVEL")))) options.level = *level;
|
||||
options.external_console = environment(L"RESKATE_LOG_CONSOLE") == L"1";
|
||||
return options;
|
||||
}
|
||||
namespace detail {
|
||||
std::string utf8(std::wstring_view text) {
|
||||
if (text.empty()) return {};
|
||||
const auto size = WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string result(static_cast<std::size_t>(size), '\0');
|
||||
if (size) WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
std::string clean(std::string_view text) {
|
||||
constexpr std::size_t limit = 8192;
|
||||
std::string result; result.reserve(std::min(text.size(), limit));
|
||||
for (std::size_t i = 0; i < text.size() && result.size() < limit; ++i) {
|
||||
const auto ch = static_cast<unsigned char>(text[i]);
|
||||
if (ch == 0x1b) {
|
||||
if (i + 1 < text.size() && text[i + 1] == '[') {
|
||||
i += 2;
|
||||
while (i < text.size() && !(text[i] >= '@' && text[i] <= '~')) ++i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\r') continue;
|
||||
result += ch >= 0x20 || ch == '\n' || ch == '\t' ? static_cast<char>(ch) : '?';
|
||||
}
|
||||
if (text.size() > limit && result.size() == limit) {
|
||||
while (!result.empty() && (static_cast<unsigned char>(result.back()) & 0xc0) == 0x80) result.pop_back();
|
||||
if (!result.empty() && static_cast<unsigned char>(result.back()) >= 0xc0) result.pop_back();
|
||||
result += "... [truncated]";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk::logging {
|
||||
enum class Level { trace, debug, info, success, warning, error, critical, off };
|
||||
// Native execution context is independent of the feature/channel. For example,
|
||||
// both client and server can report Player or Level messages.
|
||||
enum class Context { sdk, client, server, ui, engine, filesystem, material, audio, count };
|
||||
inline constexpr std::size_t context_count = static_cast<std::size_t>(Context::count);
|
||||
inline constexpr std::array<std::string_view, context_count> context_names{
|
||||
"SDK", "Native(C)", "Native(S)", "Native(U)", "Native(E)", "Native(F)", "Native(M)", "Native(A)"};
|
||||
inline constexpr std::string_view name(Context context) noexcept {
|
||||
const auto index = static_cast<std::size_t>(context);
|
||||
return index < context_names.size() ? context_names[index] : "SDK";
|
||||
}
|
||||
enum class Channel {
|
||||
runtime, level, player, skater, flow, command, launcher, hooks, graphics, input,
|
||||
ui, profile, progression, customization, world, objects, settings, music, news,
|
||||
assets, network, diagnostics, park, subworld, count
|
||||
};
|
||||
inline constexpr std::size_t channel_count = static_cast<std::size_t>(Channel::count);
|
||||
inline constexpr std::array<std::string_view, channel_count> channel_names{
|
||||
"Runtime", "Level", "Player", "Skater", "Flow", "Console", "Launcher", "Hooks",
|
||||
"Graphics", "Input", "UI", "Profile", "Progression", "Customization", "World",
|
||||
"Objects", "Settings", "Music", "News", "Assets", "Network", "Diagnostics", "Park", "Subworld"};
|
||||
// Defaults for existing subsystem callsites; hooks with a different native owner
|
||||
// must pass their context explicitly instead of deriving it from message text.
|
||||
inline constexpr Context default_context(Channel channel) noexcept {
|
||||
switch (channel) {
|
||||
case Channel::level: case Channel::player: case Channel::skater:
|
||||
case Channel::input: case Channel::profile: case Channel::progression:
|
||||
case Channel::customization: case Channel::settings: return Context::client;
|
||||
case Channel::ui: case Channel::flow: case Channel::news: return Context::ui;
|
||||
case Channel::graphics: return Context::material;
|
||||
case Channel::world: case Channel::objects: case Channel::network:
|
||||
case Channel::park: case Channel::subworld: return Context::engine;
|
||||
case Channel::assets: return Context::filesystem;
|
||||
case Channel::music: return Context::audio;
|
||||
default: return Context::sdk;
|
||||
}
|
||||
}
|
||||
inline constexpr std::string_view name(Channel channel) noexcept {
|
||||
const auto index = static_cast<std::size_t>(channel);
|
||||
return index < channel_names.size() ? channel_names[index] : "Runtime";
|
||||
}
|
||||
inline constexpr std::string_view name(Level level) noexcept {
|
||||
switch (level) {
|
||||
case Level::trace: return "TRACE";
|
||||
case Level::debug: return "DEBUG";
|
||||
case Level::info: return "INFO";
|
||||
case Level::success: return "SUCCESS";
|
||||
case Level::warning: return "WARNING";
|
||||
case Level::error: return "ERROR";
|
||||
case Level::critical: return "CRITICAL";
|
||||
default: return "OFF";
|
||||
}
|
||||
}
|
||||
inline constexpr std::optional<Level> parse_level(std::string_view value) noexcept {
|
||||
if (value == "trace") return Level::trace;
|
||||
if (value == "debug") return Level::debug;
|
||||
if (value == "info") return Level::info;
|
||||
if (value == "warning" || value == "warn") return Level::warning;
|
||||
if (value == "error") return Level::error;
|
||||
if (value == "critical") return Level::critical;
|
||||
if (value == "off") return Level::off;
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
#include "launcher_support.h"
|
||||
#include "launcher_support_internal.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <TlHelp32.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dingosdk::launcher {
|
||||
namespace {
|
||||
using detail::fail;
|
||||
using detail::fail_windows;
|
||||
using detail::Handle;
|
||||
|
||||
bool executable(DWORD protection) {
|
||||
if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false;
|
||||
switch (protection & 0xff) {
|
||||
case PAGE_EXECUTE:
|
||||
case PAGE_EXECUTE_READ:
|
||||
case PAGE_EXECUTE_READWRITE:
|
||||
case PAGE_EXECUTE_WRITECOPY:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteEntrypointPatch {
|
||||
public:
|
||||
RemoteEntrypointPatch(HANDLE process, std::uintptr_t address)
|
||||
: process_(process), address_(reinterpret_cast<void*>(address)) {
|
||||
MEMORY_BASIC_INFORMATION memory{};
|
||||
if (!VirtualQueryEx(process_, address_, &memory, sizeof(memory)))
|
||||
fail_windows("Cannot inspect the executable entrypoint");
|
||||
const auto region_end = reinterpret_cast<std::uintptr_t>(memory.BaseAddress) +
|
||||
memory.RegionSize;
|
||||
if (memory.State != MEM_COMMIT || memory.Type != MEM_IMAGE ||
|
||||
!executable(memory.Protect) || address > region_end ||
|
||||
original_.size() > region_end - address)
|
||||
fail("Executable entrypoint is not in a writable image range");
|
||||
SIZE_T count{};
|
||||
if (!ReadProcessMemory(process_, address_, original_.data(), original_.size(), &count) ||
|
||||
count != original_.size())
|
||||
fail_windows("Cannot read the executable entrypoint");
|
||||
constexpr std::array<unsigned char, 2> spin{0xeb, 0xfe};
|
||||
if (original_ == spin) fail("Executable entrypoint is already patched");
|
||||
DWORD protection{};
|
||||
if (!VirtualProtectEx(process_, address_, spin.size(), PAGE_EXECUTE_READWRITE, &protection))
|
||||
fail_windows("Cannot make the executable entrypoint writable");
|
||||
original_protection_ = protection;
|
||||
const auto wrote = WriteProcessMemory(process_, address_, spin.data(), spin.size(), &count) &&
|
||||
count == spin.size();
|
||||
if (wrote) active_ = true;
|
||||
DWORD ignored{};
|
||||
const auto protected_again = VirtualProtectEx(
|
||||
process_, address_, spin.size(), original_protection_, &ignored) != FALSE;
|
||||
if (!wrote) fail_windows("Cannot install the executable entrypoint gate");
|
||||
if (!FlushInstructionCache(process_, address_, spin.size()))
|
||||
fail_windows("Cannot flush the executable entrypoint gate");
|
||||
if (!protected_again)
|
||||
fail_windows("Cannot restore executable entrypoint protection");
|
||||
}
|
||||
|
||||
~RemoteEntrypointPatch() { restore_noexcept(); }
|
||||
RemoteEntrypointPatch(const RemoteEntrypointPatch&) = delete;
|
||||
RemoteEntrypointPatch& operator=(const RemoteEntrypointPatch&) = delete;
|
||||
|
||||
void restore() {
|
||||
if (!active_) return;
|
||||
DWORD protection{};
|
||||
if (!VirtualProtectEx(process_, address_, original_.size(), PAGE_EXECUTE_READWRITE,
|
||||
&protection))
|
||||
fail_windows("Cannot make the gated entrypoint writable for restoration");
|
||||
SIZE_T count{};
|
||||
const auto wrote = WriteProcessMemory(process_, address_, original_.data(),
|
||||
original_.size(), &count) && count == original_.size();
|
||||
const auto flushed = wrote && FlushInstructionCache(
|
||||
process_, address_, original_.size()) != FALSE;
|
||||
DWORD ignored{};
|
||||
const auto protected_again = VirtualProtectEx(process_, address_, original_.size(),
|
||||
original_protection_, &ignored) != FALSE;
|
||||
if (!wrote) fail_windows("Cannot restore the executable entrypoint");
|
||||
if (!flushed) fail_windows("Cannot flush the restored executable entrypoint");
|
||||
if (!protected_again) fail_windows("Cannot restore executable entrypoint protection");
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
private:
|
||||
void restore_noexcept() noexcept {
|
||||
if (!active_) return;
|
||||
DWORD protection{};
|
||||
if (!VirtualProtectEx(process_, address_, original_.size(), PAGE_EXECUTE_READWRITE,
|
||||
&protection))
|
||||
return;
|
||||
SIZE_T count{};
|
||||
if (WriteProcessMemory(process_, address_, original_.data(), original_.size(), &count) &&
|
||||
count == original_.size())
|
||||
FlushInstructionCache(process_, address_, original_.size());
|
||||
DWORD ignored{};
|
||||
VirtualProtectEx(process_, address_, original_.size(), original_protection_, &ignored);
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
HANDLE process_{};
|
||||
void* address_{};
|
||||
std::array<unsigned char, 2> original_{};
|
||||
DWORD original_protection_{};
|
||||
bool active_{};
|
||||
};
|
||||
|
||||
// `size` bytes at `rva` of a PE image as the loader maps it at `base`, read from
|
||||
// the file on disk and relocated. Overlays and security tools hook LoadLibraryW
|
||||
// in running processes, so the file is the reference that no one has patched.
|
||||
std::vector<unsigned char> image_file_bytes(const fs::path& file, std::uint32_t rva, std::size_t size, std::uintptr_t base) {
|
||||
std::vector<unsigned char> image;
|
||||
{
|
||||
HANDLE handle = CreateFileW(file.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (handle == INVALID_HANDLE_VALUE) return {};
|
||||
LARGE_INTEGER length{};
|
||||
if (GetFileSizeEx(handle, &length) && length.QuadPart > 0 && length.QuadPart < 64ll * 1024 * 1024) {
|
||||
image.resize(static_cast<std::size_t>(length.QuadPart));
|
||||
DWORD read{};
|
||||
if (!ReadFile(handle, image.data(), static_cast<DWORD>(image.size()), &read, nullptr) || read != image.size())
|
||||
image.clear();
|
||||
}
|
||||
CloseHandle(handle);
|
||||
}
|
||||
const auto in_file = [&](std::size_t offset, std::size_t count) { return offset <= image.size() && count <= image.size() - offset; };
|
||||
if (!in_file(0, sizeof(IMAGE_DOS_HEADER))) return {};
|
||||
IMAGE_DOS_HEADER dos{};
|
||||
std::memcpy(&dos, image.data(), sizeof(dos));
|
||||
if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0 || !in_file(static_cast<std::size_t>(dos.e_lfanew), sizeof(IMAGE_NT_HEADERS64)))
|
||||
return {};
|
||||
IMAGE_NT_HEADERS64 nt{};
|
||||
std::memcpy(&nt, image.data() + dos.e_lfanew, sizeof(nt));
|
||||
if (nt.Signature != IMAGE_NT_SIGNATURE || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) return {};
|
||||
const auto sections_at = static_cast<std::size_t>(dos.e_lfanew) + offsetof(IMAGE_NT_HEADERS64, OptionalHeader) +
|
||||
nt.FileHeader.SizeOfOptionalHeader;
|
||||
const auto file_offset = [&](std::uint32_t address, std::size_t count) -> std::optional<std::size_t> {
|
||||
for (unsigned i = 0; i < nt.FileHeader.NumberOfSections; ++i) {
|
||||
const auto at = sections_at + i * sizeof(IMAGE_SECTION_HEADER);
|
||||
if (!in_file(at, sizeof(IMAGE_SECTION_HEADER))) return std::nullopt;
|
||||
IMAGE_SECTION_HEADER section{};
|
||||
std::memcpy(§ion, image.data() + at, sizeof(section));
|
||||
if (address >= section.VirtualAddress && address - section.VirtualAddress + count <= section.SizeOfRawData) {
|
||||
const auto offset = static_cast<std::size_t>(section.PointerToRawData) + (address - section.VirtualAddress);
|
||||
return in_file(offset, count) ? std::optional(offset) : std::nullopt;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
// Read with 8 bytes either side so a relocation straddling an edge is still applied whole.
|
||||
constexpr std::uint32_t pad = 8;
|
||||
if (rva < pad) return {};
|
||||
const auto window = file_offset(rva - pad, size + pad * 2);
|
||||
if (!window) return {};
|
||||
std::vector<unsigned char> bytes(image.begin() + static_cast<std::ptrdiff_t>(*window),
|
||||
image.begin() + static_cast<std::ptrdiff_t>(*window + size + pad * 2));
|
||||
const auto delta = base - static_cast<std::uintptr_t>(nt.OptionalHeader.ImageBase);
|
||||
const auto& relocations = nt.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
|
||||
if (delta && relocations.Size) {
|
||||
const auto table = file_offset(relocations.VirtualAddress, relocations.Size);
|
||||
if (!table) return {};
|
||||
for (std::size_t at = 0; at + sizeof(IMAGE_BASE_RELOCATION) <= relocations.Size;) {
|
||||
IMAGE_BASE_RELOCATION block{};
|
||||
std::memcpy(&block, image.data() + *table + at, sizeof(block));
|
||||
if (block.SizeOfBlock < sizeof(block) || block.SizeOfBlock > relocations.Size - at) return {};
|
||||
for (std::size_t entry = sizeof(block); entry + 2 <= block.SizeOfBlock; entry += 2) {
|
||||
std::uint16_t value{};
|
||||
std::memcpy(&value, image.data() + *table + at + entry, sizeof(value));
|
||||
if ((value >> 12) != IMAGE_REL_BASED_DIR64) continue;
|
||||
const std::uint64_t target = static_cast<std::uint64_t>(block.VirtualAddress) + (value & 0xfff);
|
||||
if (target < rva - pad || target + 8 > static_cast<std::uint64_t>(rva) + size + pad) continue;
|
||||
std::uint64_t pointer{};
|
||||
std::memcpy(&pointer, bytes.data() + (target - (rva - pad)), sizeof(pointer));
|
||||
pointer += delta;
|
||||
std::memcpy(bytes.data() + (target - (rva - pad)), &pointer, sizeof(pointer));
|
||||
}
|
||||
at += block.SizeOfBlock;
|
||||
}
|
||||
}
|
||||
return std::vector<unsigned char>(bytes.begin() + pad, bytes.begin() + pad + static_cast<std::ptrdiff_t>(size));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
LoaderGate::~LoaderGate() {
|
||||
for (const auto& thread : threads_) if (thread.handle) CloseHandle(thread.handle);
|
||||
}
|
||||
|
||||
LoaderGate::LoaderGate(LoaderGate&& other) noexcept
|
||||
: threads_(std::move(other.threads_)), entrypoint_(other.entrypoint_),
|
||||
exiting_threads_(other.exiting_threads_), resumed_(other.resumed_) {
|
||||
other.threads_.clear();
|
||||
other.resumed_ = true;
|
||||
}
|
||||
|
||||
LoaderGate& LoaderGate::operator=(LoaderGate&& other) noexcept {
|
||||
if (this == &other) return *this;
|
||||
for (const auto& thread : threads_) if (thread.handle) CloseHandle(thread.handle);
|
||||
threads_ = std::move(other.threads_);
|
||||
entrypoint_ = other.entrypoint_;
|
||||
exiting_threads_ = other.exiting_threads_;
|
||||
resumed_ = other.resumed_;
|
||||
other.threads_.clear();
|
||||
other.resumed_ = true;
|
||||
return *this;
|
||||
}
|
||||
|
||||
void LoaderGate::resume() {
|
||||
if (resumed_) fail("Loader-gated threads were already resumed");
|
||||
const auto resume_group = [this](bool primary) {
|
||||
for (const auto& thread : threads_) {
|
||||
if (thread.primary != primary) continue;
|
||||
const auto previous = ResumeThread(thread.handle);
|
||||
if (previous == static_cast<DWORD>(-1) && !thread.primary &&
|
||||
WaitForSingleObject(thread.handle, 0) == WAIT_OBJECT_0)
|
||||
continue;
|
||||
if (previous == static_cast<DWORD>(-1))
|
||||
fail_windows("Cannot restore a loader-gated thread");
|
||||
if (previous != thread.previous_suspend_count + 1)
|
||||
fail("A loader-gated thread has an unexpected suspend count");
|
||||
}
|
||||
};
|
||||
// Keep the executable entrypoint gated until all pre-existing worker
|
||||
// threads have had their original suspension counts restored.
|
||||
resume_group(false);
|
||||
resume_group(true);
|
||||
resumed_ = true;
|
||||
}
|
||||
|
||||
LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread,
|
||||
DWORD process_id, std::uintptr_t image_base) {
|
||||
if (!process || !primary_thread || !image_base || GetProcessId(process) != process_id)
|
||||
fail("Invalid suspended process handles");
|
||||
const auto primary_id = GetThreadId(primary_thread);
|
||||
if (!primary_id) fail_windows("Cannot identify the suspended primary thread");
|
||||
|
||||
IMAGE_DOS_HEADER dos{};
|
||||
SIZE_T count{};
|
||||
if (!ReadProcessMemory(process, reinterpret_cast<const void*>(image_base), &dos,
|
||||
sizeof(dos), &count) || count != sizeof(dos) ||
|
||||
dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0 || dos.e_lfanew > 0x100000)
|
||||
fail("Cannot validate the gated executable DOS header");
|
||||
IMAGE_NT_HEADERS64 nt{};
|
||||
if (!ReadProcessMemory(process,
|
||||
reinterpret_cast<const void*>(image_base + static_cast<std::uintptr_t>(dos.e_lfanew)),
|
||||
&nt, sizeof(nt), &count) || count != sizeof(nt) ||
|
||||
nt.Signature != IMAGE_NT_SIGNATURE ||
|
||||
nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 ||
|
||||
nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC ||
|
||||
!nt.OptionalHeader.AddressOfEntryPoint ||
|
||||
nt.OptionalHeader.AddressOfEntryPoint >= nt.OptionalHeader.SizeOfImage)
|
||||
fail("Cannot validate the gated executable PE header");
|
||||
const auto entrypoint = image_base + nt.OptionalHeader.AddressOfEntryPoint;
|
||||
if (entrypoint < image_base) fail("Executable entrypoint address overflowed");
|
||||
RemoteEntrypointPatch patch(process, entrypoint);
|
||||
|
||||
bool primary_running = false;
|
||||
try {
|
||||
const auto initial_suspend_count = ResumeThread(primary_thread);
|
||||
if (initial_suspend_count == static_cast<DWORD>(-1))
|
||||
fail_windows("Cannot begin Windows loader initialization");
|
||||
if (initial_suspend_count != 1) {
|
||||
SuspendThread(primary_thread);
|
||||
fail("Primary thread did not have the expected creation suspension");
|
||||
}
|
||||
primary_running = true;
|
||||
|
||||
LoaderGate gate;
|
||||
gate.entrypoint_ = entrypoint;
|
||||
const auto deadline = GetTickCount64() + 30000;
|
||||
for (;;) {
|
||||
if (GetTickCount64() >= deadline)
|
||||
fail("Windows loader initialization timed out before the executable entrypoint");
|
||||
const auto previous = SuspendThread(primary_thread);
|
||||
if (previous == static_cast<DWORD>(-1))
|
||||
fail_windows("Cannot sample the executable primary thread");
|
||||
primary_running = false;
|
||||
if (previous != 0)
|
||||
fail("Executable primary thread acquired an unexpected suspension");
|
||||
CONTEXT context{};
|
||||
context.ContextFlags = CONTEXT_CONTROL;
|
||||
if (!GetThreadContext(primary_thread, &context))
|
||||
fail_windows("Cannot read the executable primary-thread context");
|
||||
if (context.Rip == entrypoint) {
|
||||
HANDLE owned_primary{};
|
||||
if (!DuplicateHandle(GetCurrentProcess(), primary_thread, GetCurrentProcess(),
|
||||
&owned_primary, 0, FALSE, DUPLICATE_SAME_ACCESS))
|
||||
fail_windows("Cannot retain the gated primary thread");
|
||||
gate.threads_.push_back({owned_primary, primary_id, previous, true});
|
||||
break;
|
||||
}
|
||||
const auto resumed = ResumeThread(primary_thread);
|
||||
if (resumed == static_cast<DWORD>(-1))
|
||||
fail_windows("Cannot continue Windows loader initialization");
|
||||
if (resumed != 1)
|
||||
fail("Executable primary thread changed suspension while sampled");
|
||||
primary_running = true;
|
||||
Sleep(1);
|
||||
}
|
||||
|
||||
// Once the primary thread is held in the entrypoint loop, iteratively
|
||||
// capture loader-created workers. Repeating until a stable snapshot
|
||||
// closes the creation race: after every observed thread is suspended,
|
||||
// no target thread remains able to create another one.
|
||||
const auto suspend_thread = reinterpret_cast<LONG(NTAPI*)(HANDLE, PULONG)>(
|
||||
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtSuspendThread"));
|
||||
if (!suspend_thread) fail_windows("Cannot locate NtSuspendThread");
|
||||
constexpr LONG status_invalid_handle = static_cast<LONG>(0xC0000008);
|
||||
constexpr LONG status_thread_is_terminating = static_cast<LONG>(0xC000004B);
|
||||
std::vector<DWORD> exiting;
|
||||
bool stable = false;
|
||||
for (unsigned pass = 0; pass < 16 && !stable; ++pass) {
|
||||
stable = true;
|
||||
Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0));
|
||||
if (snapshot.get() == INVALID_HANDLE_VALUE)
|
||||
fail_windows("Snapshot loader-initialized threads");
|
||||
THREADENTRY32 thread{sizeof(thread)};
|
||||
if (!Thread32First(snapshot.get(), &thread))
|
||||
fail_windows("Enumerate loader-initialized threads");
|
||||
do {
|
||||
if (thread.th32OwnerProcessID != process_id || thread.th32ThreadID == primary_id) {
|
||||
thread.dwSize = sizeof(thread);
|
||||
continue;
|
||||
}
|
||||
const auto known = std::any_of(gate.threads_.begin(), gate.threads_.end(),
|
||||
[&thread](const LoaderGate::Thread& captured) {
|
||||
return captured.id == thread.th32ThreadID;
|
||||
});
|
||||
if (known) {
|
||||
thread.dwSize = sizeof(thread);
|
||||
continue;
|
||||
}
|
||||
const auto handle = OpenThread(THREAD_SUSPEND_RESUME | SYNCHRONIZE |
|
||||
THREAD_QUERY_LIMITED_INFORMATION, FALSE, thread.th32ThreadID);
|
||||
if (!handle) {
|
||||
if (GetLastError() == ERROR_INVALID_PARAMETER) {
|
||||
thread.dwSize = sizeof(thread);
|
||||
continue;
|
||||
}
|
||||
fail_windows("Open loader-initialized thread");
|
||||
}
|
||||
ULONG previous{};
|
||||
const auto status = suspend_thread(handle, &previous);
|
||||
if (status < 0) {
|
||||
CloseHandle(handle);
|
||||
// A thread that is already exiting (loader-time helper threads of
|
||||
// injected mods such as OptiScaler) can never run code again, so it
|
||||
// needs no gate. SuspendThread reports it only as access denied.
|
||||
if (status == status_thread_is_terminating) {
|
||||
if (std::find(exiting.begin(), exiting.end(), thread.th32ThreadID) == exiting.end())
|
||||
exiting.push_back(thread.th32ThreadID);
|
||||
thread.dwSize = sizeof(thread);
|
||||
continue;
|
||||
}
|
||||
if (status == status_invalid_handle) {
|
||||
thread.dwSize = sizeof(thread);
|
||||
continue;
|
||||
}
|
||||
std::ostringstream detail;
|
||||
detail << "Suspend loader-initialized thread " << thread.th32ThreadID
|
||||
<< " failed (NTSTATUS 0x" << std::hex << static_cast<ULONG>(status) << ')';
|
||||
throw std::runtime_error(detail.str());
|
||||
}
|
||||
gate.threads_.push_back({handle, thread.th32ThreadID, previous, false});
|
||||
stable = false;
|
||||
thread.dwSize = sizeof(thread);
|
||||
} while (Thread32Next(snapshot.get(), &thread));
|
||||
if (GetLastError() != ERROR_NO_MORE_FILES)
|
||||
fail_windows("Enumerate loader-initialized threads");
|
||||
}
|
||||
if (!stable) fail("Loader-created threads did not reach a stable gated set");
|
||||
gate.exiting_threads_ = exiting.size();
|
||||
|
||||
patch.restore();
|
||||
return gate;
|
||||
} catch (...) {
|
||||
if (primary_running) SuspendThread(primary_thread);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
std::uintptr_t validated_remote_load_library(HANDLE process) {
|
||||
const auto kernel32 = GetModuleHandleW(L"kernel32.dll");
|
||||
if (!kernel32) fail_windows("Cannot locate local kernel32.dll");
|
||||
const auto symbol = GetProcAddress(kernel32, "LoadLibraryW");
|
||||
if (!symbol) fail_windows("Cannot locate local LoadLibraryW");
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(symbol);
|
||||
|
||||
MEMORY_BASIC_INFORMATION local{}, remote{};
|
||||
if (!VirtualQuery(reinterpret_cast<const void*>(address), &local, sizeof(local)))
|
||||
fail_windows("Cannot inspect local LoadLibraryW");
|
||||
if (!VirtualQueryEx(process, reinterpret_cast<const void*>(address), &remote, sizeof(remote)))
|
||||
fail_windows("Cannot inspect child LoadLibraryW");
|
||||
if (local.State != MEM_COMMIT || remote.State != MEM_COMMIT ||
|
||||
local.Type != MEM_IMAGE || remote.Type != MEM_IMAGE ||
|
||||
!executable(local.Protect) || !executable(remote.Protect) ||
|
||||
local.AllocationBase != remote.AllocationBase) {
|
||||
std::ostringstream detail;
|
||||
detail << "The child does not share the validated LoadLibraryW KnownDLL mapping"
|
||||
<< " (local base=" << local.AllocationBase << " state=" << std::hex << local.State
|
||||
<< " type=" << local.Type << " protect=" << local.Protect
|
||||
<< ", remote base=" << remote.AllocationBase << " state=" << remote.State
|
||||
<< " type=" << remote.Type << " protect=" << remote.Protect << ')';
|
||||
throw std::runtime_error(detail.str());
|
||||
}
|
||||
|
||||
wchar_t provider_path[32768]{};
|
||||
const auto provider_length = GetModuleFileNameW(
|
||||
reinterpret_cast<HMODULE>(local.AllocationBase), provider_path, 32768);
|
||||
if (!provider_length || provider_length >= 32768)
|
||||
fail_windows("Cannot identify the LoadLibraryW provider");
|
||||
const auto provider_name = fs::path(std::wstring(provider_path, provider_length)).filename();
|
||||
if (_wcsicmp(provider_name.c_str(), L"kernel32.dll") != 0 &&
|
||||
_wcsicmp(provider_name.c_str(), L"kernelbase.dll") != 0)
|
||||
fail("LoadLibraryW is not owned by an expected Windows KnownDLL");
|
||||
|
||||
const auto local_base = reinterpret_cast<std::uintptr_t>(local.AllocationBase);
|
||||
const auto remote_base = reinterpret_cast<std::uintptr_t>(remote.AllocationBase);
|
||||
const auto dos = *reinterpret_cast<const IMAGE_DOS_HEADER*>(local_base);
|
||||
if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0 || dos.e_lfanew > 0x100000)
|
||||
fail("Local LoadLibraryW provider has an invalid image header");
|
||||
const auto local_nt = *reinterpret_cast<const IMAGE_NT_HEADERS64*>(
|
||||
local_base + static_cast<std::uintptr_t>(dos.e_lfanew));
|
||||
IMAGE_DOS_HEADER remote_dos{};
|
||||
SIZE_T read{};
|
||||
if (!ReadProcessMemory(process, reinterpret_cast<const void*>(remote_base), &remote_dos,
|
||||
sizeof(remote_dos), &read) || read != sizeof(remote_dos) ||
|
||||
remote_dos.e_magic != dos.e_magic || remote_dos.e_lfanew != dos.e_lfanew)
|
||||
fail_windows("Cannot validate the child LoadLibraryW provider header");
|
||||
IMAGE_NT_HEADERS64 remote_nt{};
|
||||
if (!ReadProcessMemory(process,
|
||||
reinterpret_cast<const void*>(remote_base + static_cast<std::uintptr_t>(remote_dos.e_lfanew)),
|
||||
&remote_nt, sizeof(remote_nt), &read) || read != sizeof(remote_nt) ||
|
||||
remote_nt.Signature != IMAGE_NT_SIGNATURE ||
|
||||
remote_nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 ||
|
||||
remote_nt.FileHeader.TimeDateStamp != local_nt.FileHeader.TimeDateStamp ||
|
||||
remote_nt.OptionalHeader.SizeOfImage != local_nt.OptionalHeader.SizeOfImage ||
|
||||
remote_nt.OptionalHeader.CheckSum != local_nt.OptionalHeader.CheckSum)
|
||||
fail("The child LoadLibraryW provider image does not match this process");
|
||||
if (address < remote_base || address - remote_base >= remote_nt.OptionalHeader.SizeOfImage)
|
||||
fail("Child LoadLibraryW is outside its provider image");
|
||||
|
||||
constexpr std::size_t fingerprint_size = 32;
|
||||
const auto local_region_end = reinterpret_cast<std::uintptr_t>(local.BaseAddress) + local.RegionSize;
|
||||
const auto remote_region_end = reinterpret_cast<std::uintptr_t>(remote.BaseAddress) + remote.RegionSize;
|
||||
if (address > local_region_end || fingerprint_size > local_region_end - address ||
|
||||
address > remote_region_end || fingerprint_size > remote_region_end - address)
|
||||
fail("LoadLibraryW entry bytes cross an unexpected memory region");
|
||||
std::array<unsigned char, fingerprint_size> local_bytes{}, actual{};
|
||||
std::memcpy(local_bytes.data(), reinterpret_cast<const void*>(address), local_bytes.size());
|
||||
if (!ReadProcessMemory(process, reinterpret_cast<const void*>(address), actual.data(),
|
||||
actual.size(), &read) || read != actual.size())
|
||||
fail_windows("Cannot read the child LoadLibraryW entry");
|
||||
// The reference is the provider file on disk: overlays (MSI Afterburner/RTSS,
|
||||
// Discord, OBS, Overwolf) and security tools often hook LoadLibraryW in the
|
||||
// launcher's own window process, while the suspended child is still pristine.
|
||||
const auto clean = image_file_bytes(fs::path(std::wstring(provider_path, provider_length)),
|
||||
static_cast<std::uint32_t>(address - local_base), fingerprint_size, remote_base);
|
||||
const bool matches_file = clean.size() == actual.size() && std::equal(clean.begin(), clean.end(), actual.begin());
|
||||
if (!matches_file && actual != local_bytes)
|
||||
fail("Child LoadLibraryW entry bytes match neither Windows' kernel32 file nor this process. "
|
||||
"Security software may be hooking new processes; try allowing ReSkateLauncher.exe and Skate.exe.");
|
||||
return address;
|
||||
}
|
||||
|
||||
} // namespace dingosdk::launcher
|
||||
@@ -0,0 +1,232 @@
|
||||
#include "launcher_support.h"
|
||||
#include "launcher_support_internal.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <bcrypt.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dingosdk::launcher {
|
||||
namespace {
|
||||
using detail::fail;
|
||||
using detail::Handle;
|
||||
|
||||
class Algorithm {
|
||||
public:
|
||||
~Algorithm() { if (value) BCryptCloseAlgorithmProvider(value, 0); }
|
||||
BCRYPT_ALG_HANDLE value{};
|
||||
};
|
||||
|
||||
class Hash {
|
||||
public:
|
||||
~Hash() { if (value) BCryptDestroyHash(value); }
|
||||
BCRYPT_HASH_HANDLE value{};
|
||||
};
|
||||
|
||||
template<class T>
|
||||
T object_at(const std::byte* data, std::size_t size, std::size_t offset) {
|
||||
if (offset > size || sizeof(T) > size - offset) fail("Truncated PE image");
|
||||
T output{};
|
||||
std::memcpy(&output, data + offset, sizeof(output));
|
||||
return output;
|
||||
}
|
||||
|
||||
class MappedFile {
|
||||
public:
|
||||
explicit MappedFile(const fs::path& path) {
|
||||
file_.reset(CreateFileW(path.c_str(), GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr));
|
||||
if (file_.get() == INVALID_HANDLE_VALUE) fail("Cannot open PE image");
|
||||
LARGE_INTEGER length{};
|
||||
if (!GetFileSizeEx(file_.get(), &length) || length.QuadPart <= 0 ||
|
||||
static_cast<unsigned long long>(length.QuadPart) >
|
||||
static_cast<unsigned long long>(std::numeric_limits<std::size_t>::max()))
|
||||
fail("Invalid PE image size");
|
||||
size_ = static_cast<std::size_t>(length.QuadPart);
|
||||
mapping_.reset(CreateFileMappingW(file_.get(), nullptr, PAGE_READONLY, 0, 0, nullptr));
|
||||
if (!mapping_.get()) fail("Cannot map PE image");
|
||||
data_ = static_cast<const std::byte*>(MapViewOfFile(mapping_.get(), FILE_MAP_READ, 0, 0, 0));
|
||||
if (!data_) fail("Cannot read mapped PE image");
|
||||
}
|
||||
~MappedFile() { if (data_) UnmapViewOfFile(data_); }
|
||||
const std::byte* data() const noexcept { return data_; }
|
||||
std::size_t size() const noexcept { return size_; }
|
||||
private:
|
||||
Handle file_;
|
||||
Handle mapping_;
|
||||
const std::byte* data_{};
|
||||
std::size_t size_{};
|
||||
};
|
||||
|
||||
struct ParsedPe {
|
||||
PeFileInfo info;
|
||||
IMAGE_NT_HEADERS64 nt{};
|
||||
std::vector<IMAGE_SECTION_HEADER> sections;
|
||||
};
|
||||
|
||||
ParsedPe parse_pe(const std::byte* data, std::size_t size) {
|
||||
const auto dos = object_at<IMAGE_DOS_HEADER>(data, size, 0);
|
||||
if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < 0)
|
||||
fail("File is not a PE image");
|
||||
const auto nt_offset = static_cast<std::size_t>(dos.e_lfanew);
|
||||
const auto nt = object_at<IMAGE_NT_HEADERS64>(data, size, nt_offset);
|
||||
if (nt.Signature != IMAGE_NT_SIGNATURE ||
|
||||
nt.FileHeader.SizeOfOptionalHeader != sizeof(IMAGE_OPTIONAL_HEADER64) ||
|
||||
nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
|
||||
fail("File is not a PE32+ image");
|
||||
if (!nt.FileHeader.NumberOfSections || nt.FileHeader.NumberOfSections > 96)
|
||||
fail("Invalid PE section table");
|
||||
const auto sections_offset = nt_offset + sizeof(DWORD) + sizeof(IMAGE_FILE_HEADER) +
|
||||
nt.FileHeader.SizeOfOptionalHeader;
|
||||
std::vector<IMAGE_SECTION_HEADER> sections;
|
||||
sections.reserve(nt.FileHeader.NumberOfSections);
|
||||
for (std::size_t index = 0; index < nt.FileHeader.NumberOfSections; ++index)
|
||||
sections.push_back(object_at<IMAGE_SECTION_HEADER>(data, size,
|
||||
sections_offset + index * sizeof(IMAGE_SECTION_HEADER)));
|
||||
return {{nt.FileHeader.Machine, nt.FileHeader.Characteristics,
|
||||
nt.OptionalHeader.SizeOfImage, nt.OptionalHeader.SizeOfHeaders, true},
|
||||
nt, std::move(sections)};
|
||||
}
|
||||
|
||||
std::size_t rva_offset(const ParsedPe& pe, std::size_t file_size, std::uint32_t rva,
|
||||
std::size_t required) {
|
||||
if (rva < pe.info.headers_size) {
|
||||
if (rva > file_size || required > file_size - rva) fail("PE RVA is outside the file");
|
||||
return rva;
|
||||
}
|
||||
for (const auto& section : pe.sections) {
|
||||
const std::uint64_t start = section.VirtualAddress;
|
||||
const std::uint64_t end = start + std::max(section.Misc.VirtualSize, section.SizeOfRawData);
|
||||
if (rva < start || rva >= end) continue;
|
||||
const auto delta = static_cast<std::uint64_t>(rva) - start;
|
||||
if (delta > section.SizeOfRawData || required > section.SizeOfRawData - delta)
|
||||
fail("PE RVA has no file data");
|
||||
const auto offset = static_cast<std::uint64_t>(section.PointerToRawData) + delta;
|
||||
if (offset > file_size || required > file_size - offset) fail("PE RVA is outside the file");
|
||||
return static_cast<std::size_t>(offset);
|
||||
}
|
||||
fail("PE RVA does not belong to a section");
|
||||
}
|
||||
|
||||
template<class T>
|
||||
T rva_object(const MappedFile& file, const ParsedPe& pe, std::uint32_t rva) {
|
||||
return object_at<T>(file.data(), file.size(), rva_offset(pe, file.size(), rva, sizeof(T)));
|
||||
}
|
||||
|
||||
std::string rva_string(const MappedFile& file, const ParsedPe& pe, std::uint32_t rva) {
|
||||
const auto offset = rva_offset(pe, file.size(), rva, 1);
|
||||
std::string output;
|
||||
for (std::size_t index = offset; index < file.size() && output.size() <= 4096; ++index) {
|
||||
const auto value = static_cast<char>(file.data()[index]);
|
||||
if (!value) return output;
|
||||
output.push_back(value);
|
||||
}
|
||||
fail("Unterminated PE export name");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string sha256_file(const fs::path& path) {
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr));
|
||||
if (file.get() == INVALID_HANDLE_VALUE) fail("Cannot open file for SHA-256");
|
||||
|
||||
Algorithm algorithm;
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm.value, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0)
|
||||
fail("Cannot initialize SHA-256");
|
||||
DWORD object_size{}, result_size{};
|
||||
if (BCryptGetProperty(algorithm.value, BCRYPT_OBJECT_LENGTH,
|
||||
reinterpret_cast<PUCHAR>(&object_size), sizeof(object_size), &result_size, 0) < 0 ||
|
||||
result_size != sizeof(object_size)) fail("Cannot query SHA-256 state size");
|
||||
std::vector<UCHAR> object(object_size);
|
||||
Hash hash;
|
||||
if (BCryptCreateHash(algorithm.value, &hash.value, object.data(), object_size,
|
||||
nullptr, 0, 0) < 0) fail("Cannot create SHA-256 state");
|
||||
std::vector<UCHAR> bytes(1024 * 1024);
|
||||
for (;;) {
|
||||
DWORD count{};
|
||||
if (!ReadFile(file.get(), bytes.data(), static_cast<DWORD>(bytes.size()), &count, nullptr))
|
||||
fail("Cannot read file for SHA-256");
|
||||
if (!count) break;
|
||||
if (BCryptHashData(hash.value, bytes.data(), count, 0) < 0)
|
||||
fail("Cannot update SHA-256");
|
||||
}
|
||||
std::array<UCHAR, 32> digest{};
|
||||
if (BCryptFinishHash(hash.value, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0)
|
||||
fail("Cannot finish SHA-256");
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string output;
|
||||
output.reserve(digest.size() * 2);
|
||||
for (const auto byte : digest) {
|
||||
output.push_back(digits[byte >> 4]);
|
||||
output.push_back(digits[byte & 15]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
PeFileInfo inspect_pe_file(const fs::path& path) {
|
||||
const MappedFile file(path);
|
||||
return parse_pe(file.data(), file.size()).info;
|
||||
}
|
||||
|
||||
std::uint32_t exported_function_rva(const fs::path& path, std::string_view export_name) {
|
||||
const MappedFile file(path);
|
||||
const auto pe = parse_pe(file.data(), file.size());
|
||||
const auto& directory = pe.nt.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
|
||||
if (!directory.VirtualAddress || directory.Size < sizeof(IMAGE_EXPORT_DIRECTORY))
|
||||
fail("DLL has no export directory");
|
||||
const auto exports = rva_object<IMAGE_EXPORT_DIRECTORY>(file, pe, directory.VirtualAddress);
|
||||
if (!exports.NumberOfFunctions || exports.NumberOfNames > exports.NumberOfFunctions ||
|
||||
exports.NumberOfFunctions > 65536) fail("Invalid PE export table");
|
||||
for (std::uint32_t index = 0; index < exports.NumberOfNames; ++index) {
|
||||
const auto name_rva = rva_object<std::uint32_t>(file, pe,
|
||||
exports.AddressOfNames + index * sizeof(std::uint32_t));
|
||||
if (rva_string(file, pe, name_rva) != export_name) continue;
|
||||
const auto ordinal = rva_object<std::uint16_t>(file, pe,
|
||||
exports.AddressOfNameOrdinals + index * sizeof(std::uint16_t));
|
||||
if (ordinal >= exports.NumberOfFunctions) fail("Invalid PE export ordinal");
|
||||
const auto function = rva_object<std::uint32_t>(file, pe,
|
||||
exports.AddressOfFunctions + ordinal * sizeof(std::uint32_t));
|
||||
const std::uint64_t export_end = static_cast<std::uint64_t>(directory.VirtualAddress) + directory.Size;
|
||||
if (!function || (function >= directory.VirtualAddress && function < export_end))
|
||||
fail("Initializer export is missing or forwarded");
|
||||
return function;
|
||||
}
|
||||
fail("Required initializer export is missing");
|
||||
}
|
||||
|
||||
void validate_game_file(const fs::path& path) {
|
||||
std::error_code error;
|
||||
const auto size = fs::file_size(path, error);
|
||||
if (error || size != expected_game_file_size) fail("Skate.exe has the wrong file size");
|
||||
const auto image = inspect_pe_file(path);
|
||||
if (!image.pe64 || image.machine != IMAGE_FILE_MACHINE_AMD64 ||
|
||||
!(image.characteristics & IMAGE_FILE_EXECUTABLE_IMAGE) ||
|
||||
(image.characteristics & IMAGE_FILE_DLL) || image.image_size != expected_game_image_size)
|
||||
fail("Skate.exe has the wrong PE image identity");
|
||||
if (sha256_file(path) != expected_game_sha256)
|
||||
fail("Skate.exe SHA-256 does not match the supported build");
|
||||
}
|
||||
|
||||
void validate_steam_api_file(const fs::path& path) {
|
||||
std::error_code error;
|
||||
const auto size = fs::file_size(path, error);
|
||||
if (error || size != expected_steam_api_file_size)
|
||||
fail("steam_api64.dll is not the supported original Steam library (wrong size)");
|
||||
const auto image = inspect_pe_file(path);
|
||||
if (!image.pe64 || image.machine != IMAGE_FILE_MACHINE_AMD64 ||
|
||||
!(image.characteristics & IMAGE_FILE_EXECUTABLE_IMAGE) ||
|
||||
!(image.characteristics & IMAGE_FILE_DLL))
|
||||
fail("steam_api64.dll is not an x64 Windows DLL");
|
||||
if (sha256_file(path) != expected_steam_api_sha256)
|
||||
fail("steam_api64.dll is not the supported original Steam library (SHA-256 mismatch)");
|
||||
}
|
||||
|
||||
} // namespace dingosdk::launcher
|
||||
@@ -0,0 +1,223 @@
|
||||
#include "Engine/Core/Log/types.h"
|
||||
#include "launcher_support.h"
|
||||
#include "launcher_support_internal.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dingosdk::launcher {
|
||||
namespace {
|
||||
using detail::fail;
|
||||
} // namespace
|
||||
|
||||
bool bindable_key(unsigned key) noexcept {
|
||||
if (key == 0 || key > 0xFE) return false;
|
||||
switch (key) {
|
||||
case VK_LBUTTON: case VK_RBUTTON: case VK_MBUTTON: case VK_XBUTTON1: case VK_XBUTTON2: case VK_CANCEL:
|
||||
case VK_SHIFT: case VK_CONTROL: case VK_MENU: case VK_LSHIFT: case VK_RSHIFT: case VK_LCONTROL:
|
||||
case VK_RCONTROL: case VK_LMENU: case VK_RMENU: case VK_LWIN: case VK_RWIN: case VK_APPS:
|
||||
case VK_CAPITAL: case VK_NUMLOCK: case VK_ESCAPE: case VK_RETURN: case VK_TAB: case VK_SPACE:
|
||||
case VK_BACK: case VK_PACKET: case VK_PROCESSKEY:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
std::string key_name(unsigned key) {
|
||||
switch (key) {
|
||||
case VK_INSERT: return "Insert";
|
||||
case VK_DELETE: return "Delete";
|
||||
case VK_HOME: return "Home";
|
||||
case VK_END: return "End";
|
||||
case VK_PRIOR: return "Page Up";
|
||||
case VK_NEXT: return "Page Down";
|
||||
case VK_UP: return "Up";
|
||||
case VK_DOWN: return "Down";
|
||||
case VK_LEFT: return "Left";
|
||||
case VK_RIGHT: return "Right";
|
||||
case VK_PAUSE: return "Pause";
|
||||
case VK_SCROLL: return "Scroll Lock";
|
||||
case VK_SNAPSHOT: return "Print Screen";
|
||||
case VK_DIVIDE: return "Num /";
|
||||
case VK_OEM_3: return "` / ~";
|
||||
default: break;
|
||||
}
|
||||
if (key >= VK_F1 && key <= VK_F24) return "F" + std::to_string(key - VK_F1 + 1);
|
||||
if (key >= VK_NUMPAD0 && key <= VK_NUMPAD9) return "Num " + std::to_string(key - VK_NUMPAD0);
|
||||
const auto scan = MapVirtualKeyW(key, MAPVK_VK_TO_VSC);
|
||||
std::array<wchar_t, 64> text{};
|
||||
const auto length = scan ? GetKeyNameTextW(static_cast<LONG>(scan << 16), text.data(), static_cast<int>(text.size())) : 0;
|
||||
if (length <= 0) return "Key " + std::to_string(key);
|
||||
const auto size = WideCharToMultiByte(CP_UTF8, 0, text.data(), length, nullptr, 0, nullptr, nullptr);
|
||||
std::string result(static_cast<std::size_t>(size > 0 ? size : 0), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.data(), length, result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string key_cap(unsigned key) {
|
||||
switch (key) {
|
||||
case VK_INSERT: return "INS";
|
||||
case VK_DELETE: return "DEL";
|
||||
case VK_PRIOR: return "PGUP";
|
||||
case VK_NEXT: return "PGDN";
|
||||
default: break;
|
||||
}
|
||||
auto name = key_name(key);
|
||||
for (auto& ch : name) if (ch >= 'a' && ch <= 'z') ch = static_cast<char>(ch - ('a' - 'A'));
|
||||
return name;
|
||||
}
|
||||
|
||||
OverlayKeys overlay_keys() noexcept {
|
||||
static const OverlayKeys keys = [] {
|
||||
const auto incoming_error = GetLastError();
|
||||
const auto read = [](const wchar_t* name, unsigned fallback) {
|
||||
wchar_t value[16]{};
|
||||
const auto length = GetEnvironmentVariableW(name, value, 16);
|
||||
if (!length || length >= 16) return fallback;
|
||||
wchar_t* end{};
|
||||
const auto parsed = std::wcstoul(value, &end, 0);
|
||||
return end && *end == L'\0' && bindable_key(parsed) ? static_cast<unsigned>(parsed) : fallback;
|
||||
};
|
||||
OverlayKeys result{read(L"RESKATE_MENU_KEY", default_menu_key), read(L"RESKATE_CONSOLE_KEY", default_console_key)};
|
||||
if (result.menu == result.console) result = {};
|
||||
SetLastError(incoming_error);
|
||||
return result;
|
||||
}();
|
||||
return keys;
|
||||
}
|
||||
|
||||
bool offline_mode() noexcept {
|
||||
static const bool offline = [] {
|
||||
const auto incoming_error = GetLastError();
|
||||
wchar_t value[2]{};
|
||||
const auto length = GetEnvironmentVariableW(L"RESKATE_OFFLINE", value, 2);
|
||||
SetLastError(incoming_error);
|
||||
return length == 1 && value[0] == L'1';
|
||||
}();
|
||||
return offline;
|
||||
}
|
||||
|
||||
std::uint64_t offline_steam_id() noexcept {
|
||||
static const std::uint64_t id = [] {
|
||||
constexpr std::uint64_t fallback = 0x0110000100000001ull;
|
||||
const auto incoming_error = GetLastError();
|
||||
char value[32]{};
|
||||
const auto length = GetEnvironmentVariableA("RESKATE_OFFLINE_STEAM_ID", value, sizeof(value));
|
||||
SetLastError(incoming_error);
|
||||
if (!length || length >= sizeof(value)) return fallback;
|
||||
char* end{};
|
||||
const auto parsed = std::strtoull(value, &end, 10);
|
||||
// Individual accounts in the public universe only.
|
||||
return end && *end == '\0' && (parsed >> 52) == 0x011 ? parsed : fallback;
|
||||
}();
|
||||
return id;
|
||||
}
|
||||
|
||||
LaunchOptions parse_launch_options(const std::vector<std::wstring>& arguments) {
|
||||
LaunchOptions options;
|
||||
bool log_level_explicit = false;
|
||||
const auto dimension = [](std::wstring_view value, unsigned minimum) {
|
||||
unsigned result = 0;
|
||||
if (value.empty()) fail("Resolution requires a decimal pixel count");
|
||||
for (const auto character : value) {
|
||||
if (character < L'0' || character > L'9' || result > 16384)
|
||||
fail("Resolution requires a decimal pixel count, at most 16384");
|
||||
result = result * 10 + static_cast<unsigned>(character - L'0');
|
||||
}
|
||||
if (result < minimum || result > 16384)
|
||||
fail("Resolution must be at least 320 x 200 and at most 16384 x 16384");
|
||||
return result;
|
||||
};
|
||||
for (std::size_t index = 0; index < arguments.size(); ++index) {
|
||||
const std::wstring_view argument(arguments[index]);
|
||||
if (argument == L"--no-loose-files") { options.loose_files = false; continue; }
|
||||
if (argument == L"-offline" || argument == L"--offline") { options.offline = true; continue; }
|
||||
if (argument == L"-wconsole") { options.window_console = true; continue; }
|
||||
if (argument == L"--log-trace") { options.log_trace = true; continue; }
|
||||
if (argument == L"--log-level" || argument.starts_with(L"--log-level=")) {
|
||||
const auto equals = argument.find(L'=');
|
||||
std::wstring_view value;
|
||||
if (equals != std::wstring_view::npos) value = argument.substr(equals + 1);
|
||||
else {
|
||||
if (++index == arguments.size()) fail("--log-level requires a severity");
|
||||
value = arguments[index];
|
||||
}
|
||||
std::string level;
|
||||
for (auto ch : value) {
|
||||
if (ch < L'a' || ch > L'z') fail("Invalid --log-level");
|
||||
level += static_cast<char>(ch);
|
||||
}
|
||||
if (!logging::parse_level(level)) fail("--log-level requires trace, debug, info, warning, error, critical, or off");
|
||||
options.log_level = std::move(level); log_level_explicit = true; continue;
|
||||
}
|
||||
if (argument == L"--gpu-diagnostics") { options.gpu_diagnostics = true; continue; }
|
||||
const bool menu_key = argument.starts_with(L"--menu-key=");
|
||||
if (menu_key || argument.starts_with(L"--console-key=")) {
|
||||
const std::wstring value(argument.substr(argument.find(L'=') + 1));
|
||||
wchar_t* end{};
|
||||
const auto key = std::wcstoul(value.c_str(), &end, 0);
|
||||
if (value.empty() || !end || *end != L'\0' || !bindable_key(key))
|
||||
fail("--menu-key and --console-key take a virtual-key code such as 0x2D (Insert)");
|
||||
(menu_key ? options.menu_key : options.console_key) = static_cast<unsigned>(key);
|
||||
continue;
|
||||
}
|
||||
// Translate the friendly flag into the inspected engine settings below.
|
||||
if (argument == L"-windowed" || argument == L"--windowed") {
|
||||
options.force_windowed = true;
|
||||
continue;
|
||||
}
|
||||
const bool width = argument == L"--width" || argument.starts_with(L"--width=");
|
||||
const bool height = argument == L"--height" || argument.starts_with(L"--height=");
|
||||
if (width || height) {
|
||||
options.force_windowed = true;
|
||||
const auto equals = argument.find(L'=');
|
||||
std::wstring_view value;
|
||||
if (equals != std::wstring_view::npos) value = argument.substr(equals + 1);
|
||||
else {
|
||||
if (++index == arguments.size()) fail("--width and --height require a pixel count");
|
||||
value = arguments[index];
|
||||
}
|
||||
if (width) options.width = dimension(value, 320);
|
||||
else options.height = dimension(value, 200);
|
||||
continue;
|
||||
}
|
||||
options.game_arguments.emplace_back(argument);
|
||||
}
|
||||
if (options.log_trace && !log_level_explicit) options.log_level = "trace";
|
||||
return options;
|
||||
}
|
||||
|
||||
std::wstring windowed_arguments(const LaunchOptions& options) {
|
||||
if (!options.force_windowed) return {};
|
||||
return L" -Window.Width " + std::to_wstring(options.width) +
|
||||
L" -Window.Height " + std::to_wstring(options.height) +
|
||||
L" -Window.AutoSize 0 -Window.FullscreenAutoSize 0"
|
||||
L" -RenderDevice.FullscreenModeEnable 0 -RenderDevice.WindowedBorderless 0";
|
||||
}
|
||||
|
||||
std::wstring mod_data_arguments(const fs::path& game_directory,
|
||||
const std::vector<std::wstring>& game_arguments) {
|
||||
for (const auto& argument : game_arguments) {
|
||||
const std::wstring_view key(argument.data(), argument.find(L'=') == std::wstring::npos
|
||||
? argument.size() : argument.find(L'='));
|
||||
if (key.size() == 9 && CompareStringOrdinal(key.data(), 9, L"-dataPath", 9, TRUE) == CSTR_EQUAL)
|
||||
return {};
|
||||
}
|
||||
return fs::is_directory(game_directory / L"ModData")
|
||||
? L" -dataPath \"ModData/Default\"" : L"";
|
||||
}
|
||||
|
||||
SiblingPaths sibling_paths(const fs::path& launcher) {
|
||||
const auto directory = launcher.parent_path();
|
||||
return {directory, directory / L"Skate.exe", directory / L"ReSkate.dll",
|
||||
directory / L"steam_api64.dll", directory / L"logs"};
|
||||
}
|
||||
|
||||
} // namespace dingosdk::launcher
|
||||
@@ -0,0 +1,133 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::launcher {
|
||||
|
||||
inline constexpr std::uint64_t expected_game_file_size = supported_build::game_file_size;
|
||||
inline constexpr std::uint32_t expected_game_image_size = supported_build::game_image_size;
|
||||
inline constexpr std::string_view expected_game_sha256 = supported_build::game_sha256;
|
||||
inline constexpr std::uint64_t expected_steam_api_file_size = supported_build::steam_api_file_size;
|
||||
inline constexpr std::string_view expected_steam_api_sha256 = supported_build::steam_api_sha256;
|
||||
|
||||
struct SiblingPaths {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path game;
|
||||
std::filesystem::path dll;
|
||||
std::filesystem::path steam_api;
|
||||
std::filesystem::path logs;
|
||||
};
|
||||
|
||||
struct PeFileInfo {
|
||||
std::uint16_t machine{};
|
||||
std::uint16_t characteristics{};
|
||||
std::uint32_t image_size{};
|
||||
std::uint32_t headers_size{};
|
||||
bool pe64{};
|
||||
};
|
||||
|
||||
struct LaunchOptions {
|
||||
unsigned width{1280};
|
||||
unsigned height{720};
|
||||
bool force_windowed{};
|
||||
bool gpu_diagnostics{};
|
||||
bool window_console{};
|
||||
bool log_trace{};
|
||||
bool loose_files{true};
|
||||
bool offline{};
|
||||
// Virtual-key codes that open ReSkate's menu and console (--menu-key /
|
||||
// --console-key); passed to the game as RESKATE_MENU_KEY / RESKATE_CONSOLE_KEY.
|
||||
unsigned menu_key{0x2D}; // VK_INSERT
|
||||
unsigned console_key{0xC0}; // VK_OEM_3, the key left of 1
|
||||
std::string log_level{"info"};
|
||||
std::vector<std::wstring> game_arguments;
|
||||
};
|
||||
|
||||
// Arguments exclude argv[0]. Saved game display settings apply by default.
|
||||
// --windowed or an explicit width/height requests the windowed override.
|
||||
LaunchOptions parse_launch_options(const std::vector<std::wstring>& arguments);
|
||||
std::wstring windowed_arguments(const LaunchOptions& options);
|
||||
// Detect mods beside Skate.exe. Explicit game data paths take precedence.
|
||||
std::wstring mod_data_arguments(const std::filesystem::path& game_directory,
|
||||
const std::vector<std::wstring>& game_arguments);
|
||||
|
||||
class LoaderGate {
|
||||
public:
|
||||
LoaderGate() = default;
|
||||
~LoaderGate();
|
||||
LoaderGate(const LoaderGate&) = delete;
|
||||
LoaderGate& operator=(const LoaderGate&) = delete;
|
||||
LoaderGate(LoaderGate&& other) noexcept;
|
||||
LoaderGate& operator=(LoaderGate&& other) noexcept;
|
||||
|
||||
std::uintptr_t entrypoint() const noexcept { return entrypoint_; }
|
||||
std::size_t thread_count() const noexcept { return threads_.size(); }
|
||||
// Threads that were already exiting when the gate closed; they need no gate.
|
||||
std::size_t exiting_thread_count() const noexcept { return exiting_threads_; }
|
||||
void resume();
|
||||
|
||||
private:
|
||||
friend LoaderGate prepare_loader_for_injection(HANDLE, HANDLE, DWORD, std::uintptr_t);
|
||||
struct Thread {
|
||||
HANDLE handle{};
|
||||
DWORD id{};
|
||||
DWORD previous_suspend_count{};
|
||||
bool primary{};
|
||||
};
|
||||
std::vector<Thread> threads_;
|
||||
std::uintptr_t entrypoint_{};
|
||||
std::size_t exiting_threads_{};
|
||||
bool resumed_{};
|
||||
};
|
||||
|
||||
SiblingPaths sibling_paths(const std::filesystem::path& launcher);
|
||||
std::string sha256_file(const std::filesystem::path& path);
|
||||
PeFileInfo inspect_pe_file(const std::filesystem::path& path);
|
||||
std::uint32_t exported_function_rva(const std::filesystem::path& path,
|
||||
std::string_view export_name);
|
||||
void validate_game_file(const std::filesystem::path& path);
|
||||
void validate_steam_api_file(const std::filesystem::path& path);
|
||||
|
||||
// -offline: the launcher sets RESKATE_OFFLINE=1 so the game runs without Steam,
|
||||
// as "Unknown Player", with multiplayer hidden. Read once per process.
|
||||
inline constexpr const char* offline_player_name = "Unknown Player";
|
||||
bool offline_mode() noexcept;
|
||||
// The SteamID offline mode reports: RESKATE_OFFLINE_STEAM_ID, the account
|
||||
// Steam was last signed in to, so the game keeps the same settings save.
|
||||
std::uint64_t offline_steam_id() noexcept;
|
||||
|
||||
// Keys that open ReSkate's menu and console in game.
|
||||
inline constexpr unsigned default_menu_key = 0x2D; // VK_INSERT
|
||||
inline constexpr unsigned default_console_key = 0xC0; // VK_OEM_3
|
||||
struct OverlayKeys { unsigned menu{default_menu_key}, console{default_console_key}; };
|
||||
// The keys the launcher chose (RESKATE_MENU_KEY / RESKATE_CONSOLE_KEY), or
|
||||
// the defaults. Read once per process.
|
||||
OverlayKeys overlay_keys() noexcept;
|
||||
// Whether a virtual key can open the menu or console: not a mouse button,
|
||||
// modifier, Escape, Enter, Tab, Space or Backspace.
|
||||
bool bindable_key(unsigned key) noexcept;
|
||||
// "Insert", "F8", "`"... for settings and hints; `short_name` gives a keycap
|
||||
// label such as "INS".
|
||||
std::string key_name(unsigned key);
|
||||
std::string key_cap(unsigned key);
|
||||
|
||||
// Temporarily gates the validated executable entrypoint of a process created
|
||||
// with CREATE_SUSPENDED. This lets the Windows loader finish while preventing
|
||||
// executable startup, then suspends every existing thread and restores the
|
||||
// exact entrypoint bytes before returning.
|
||||
LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread,
|
||||
DWORD process_id, std::uintptr_t image_base);
|
||||
|
||||
// Returns the ordinary LoadLibraryW address for a same-architecture suspended
|
||||
// child only after verifying the matching KnownDLL mapping and entry bytes.
|
||||
std::uintptr_t validated_remote_load_library(HANDLE process);
|
||||
|
||||
} // namespace dingosdk::launcher
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
// Shared by launcher_support.cpp, launcher_pe.cpp and launcher_injection.cpp.
|
||||
namespace dingosdk::launcher::detail {
|
||||
|
||||
class Handle {
|
||||
public:
|
||||
explicit Handle(HANDLE value = nullptr) noexcept : value_(value) {}
|
||||
~Handle() { reset(); }
|
||||
Handle(const Handle&) = delete;
|
||||
Handle& operator=(const Handle&) = delete;
|
||||
Handle(Handle&& other) noexcept : value_(other.release()) {}
|
||||
Handle& operator=(Handle&& other) noexcept {
|
||||
if (this != &other) reset(other.release());
|
||||
return *this;
|
||||
}
|
||||
HANDLE get() const noexcept { return value_; }
|
||||
HANDLE release() noexcept { const auto value = value_; value_ = nullptr; return value; }
|
||||
void reset(HANDLE value = nullptr) noexcept {
|
||||
if (value_ && value_ != INVALID_HANDLE_VALUE) CloseHandle(value_);
|
||||
value_ = value;
|
||||
}
|
||||
private:
|
||||
HANDLE value_{};
|
||||
};
|
||||
|
||||
[[noreturn]] inline void fail(const char* message) { throw std::runtime_error(message); }
|
||||
|
||||
[[noreturn]] inline void fail_windows(const char* message, DWORD error = GetLastError()) {
|
||||
throw std::runtime_error(std::string(message) + " (Windows error " +
|
||||
std::to_string(error) + ")");
|
||||
}
|
||||
|
||||
} // namespace dingosdk::launcher::detail
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dingosdk::memory {
|
||||
inline constexpr std::uintptr_t highest_user_address = 0x00007fffffffffffULL;
|
||||
inline bool read_bytes(std::uintptr_t address, void* destination, std::size_t size) noexcept {
|
||||
if (!destination || !size || size > highest_user_address || address < 0x10000 ||
|
||||
address > highest_user_address - size) return false;
|
||||
SIZE_T count{};
|
||||
return ReadProcessMemory(GetCurrentProcess(), reinterpret_cast<const void*>(address), destination, size, &count) && count == size;
|
||||
}
|
||||
template<class T> requires std::is_trivially_copyable_v<T>
|
||||
bool read(std::uintptr_t address, T& value) noexcept { return read_bytes(address, &value, sizeof(value)); }
|
||||
|
||||
// The same read without a kernel transition: a plain copy that stops at an access violation.
|
||||
// ReadProcessMemory above costs a system call (about 1 us) per read, which adds up on paths
|
||||
// that run every game frame. Use peek there, for game objects expected to be readable; a
|
||||
// failed peek costs an exception, so keep read for probing addresses that are often invalid.
|
||||
inline bool peek_bytes(std::uintptr_t address, void* destination, std::size_t size) noexcept {
|
||||
if (!destination || !size || size > highest_user_address || address < 0x10000 ||
|
||||
address > highest_user_address - size) return false;
|
||||
__try {
|
||||
std::memcpy(destination, reinterpret_cast<const void*>(address), size);
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
template<class T> requires std::is_trivially_copyable_v<T>
|
||||
bool peek(std::uintptr_t address, T& value) noexcept { return peek_bytes(address, &value, sizeof(value)); }
|
||||
|
||||
// A NUL-terminated string copied the same way: the length (terminator written at destination[length]),
|
||||
// or -1 if it is unreadable or has no terminator within capacity bytes. Reads byte by byte, so it never
|
||||
// touches memory past the terminator.
|
||||
inline std::ptrdiff_t peek_cstring(std::uintptr_t address, char* destination, std::size_t capacity) noexcept {
|
||||
if (!destination || !capacity || address < 0x10000 || address > highest_user_address - capacity) return -1;
|
||||
__try {
|
||||
const auto* source = reinterpret_cast<const char*>(address);
|
||||
for (std::size_t i = 0; i < capacity; ++i)
|
||||
if (!(destination[i] = source[i])) return static_cast<std::ptrdiff_t>(i);
|
||||
return -1;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#include "profiler_internal.h"
|
||||
#include <TlHelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk::profiler {
|
||||
std::atomic<bool> zones_enabled{false};
|
||||
namespace detail {
|
||||
std::atomic<std::uint32_t> client_thread{0}, present_thread{0};
|
||||
std::atomic<bool> sampling{false};
|
||||
}
|
||||
namespace {
|
||||
std::atomic<Site*> sites{nullptr};
|
||||
std::atomic<bool> hud_visible{false}, window_visible{false}, monitor_running{false};
|
||||
|
||||
// Recent values for the HUD's graphs. One writer each (the client update thread, the present
|
||||
// thread); the overlay reads them racily, which at worst shows one value twice.
|
||||
constexpr std::size_t history_size = 512;
|
||||
struct Ring {
|
||||
std::array<std::atomic<float>, history_size> values{};
|
||||
std::atomic<std::uint64_t> written{0};
|
||||
void push(float value) noexcept {
|
||||
const auto at = written.load(std::memory_order_relaxed);
|
||||
values[at % history_size].store(value, std::memory_order_relaxed);
|
||||
written.store(at + 1, std::memory_order_release);
|
||||
}
|
||||
};
|
||||
std::array<Ring, 4> rings;
|
||||
|
||||
// Totals since the monitor's last summary, taken with exchange().
|
||||
struct Totals {
|
||||
std::atomic<std::uint64_t> count{0}, sum{0}, longest{0};
|
||||
void add(std::uint64_t value) noexcept {
|
||||
count.fetch_add(1, std::memory_order_relaxed);
|
||||
sum.fetch_add(value, std::memory_order_relaxed);
|
||||
auto previous = longest.load(std::memory_order_relaxed);
|
||||
while (value > previous && !longest.compare_exchange_weak(previous, value, std::memory_order_relaxed)) {}
|
||||
}
|
||||
struct Taken { std::uint64_t count, sum, longest; };
|
||||
Taken take() noexcept {
|
||||
return {count.exchange(0, std::memory_order_relaxed), sum.exchange(0, std::memory_order_relaxed),
|
||||
longest.exchange(0, std::memory_order_relaxed)};
|
||||
}
|
||||
};
|
||||
Totals client_interval, client_frame, client_own, present_interval;
|
||||
|
||||
std::mutex summary_mutex;
|
||||
std::shared_ptr<const Summary> latest_summary = std::make_shared<Summary>();
|
||||
|
||||
bool wanted() noexcept {
|
||||
return hud_visible.load(std::memory_order_acquire) || window_visible.load(std::memory_order_acquire) ||
|
||||
detail::sampling.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
std::uint64_t filetime(const FILETIME& value) noexcept {
|
||||
return (static_cast<std::uint64_t>(value.dwHighDateTime) << 32) | value.dwLowDateTime;
|
||||
}
|
||||
|
||||
// Per-thread CPU use, from GetThreadTimes deltas. The thread list is refreshed every few
|
||||
// seconds: a Toolhelp snapshot walks every thread on the system.
|
||||
class ThreadMonitor {
|
||||
struct Thread {
|
||||
HANDLE handle{};
|
||||
std::uint64_t last{};
|
||||
std::string label;
|
||||
bool seen{};
|
||||
};
|
||||
std::map<DWORD, Thread> threads_;
|
||||
std::uint64_t last_process_{}, last_wall_{};
|
||||
std::chrono::steady_clock::time_point next_refresh_{};
|
||||
|
||||
static std::string module_of(std::uintptr_t address) {
|
||||
HMODULE module{};
|
||||
if (!address || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(address), &module)) return {};
|
||||
wchar_t name[MAX_PATH]{};
|
||||
if (!GetModuleBaseNameW(GetCurrentProcess(), module, name, MAX_PATH)) return {};
|
||||
std::string result;
|
||||
for (const wchar_t* c = name; *c; ++c) result.push_back(*c < 128 ? static_cast<char>(*c) : '?');
|
||||
return result;
|
||||
}
|
||||
static std::string label_of(HANDLE handle) {
|
||||
PWSTR description{};
|
||||
std::string result;
|
||||
if (SUCCEEDED(GetThreadDescription(handle, &description)) && description) {
|
||||
for (const wchar_t* c = description; *c; ++c) result.push_back(*c < 128 ? static_cast<char>(*c) : '?');
|
||||
LocalFree(description);
|
||||
}
|
||||
if (!result.empty()) return result;
|
||||
using Query = LONG(NTAPI*)(HANDLE, int, PVOID, ULONG, PULONG);
|
||||
static const auto query = reinterpret_cast<Query>(
|
||||
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationThread"));
|
||||
void* start{};
|
||||
if (query) query(handle, 9 /* ThreadQuerySetWin32StartAddress */, &start, sizeof(start), nullptr);
|
||||
auto module = module_of(reinterpret_cast<std::uintptr_t>(start));
|
||||
std::string lower = module;
|
||||
for (auto& c : lower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
// Frostbite starts its threads through the CRT, so they all begin in ucrtbase.
|
||||
if (lower == "reskate.dll") return "ReSkate worker";
|
||||
if (lower == "ucrtbase.dll" || lower == "skate.exe") return "Skate";
|
||||
if (lower.starts_with("steam") || lower.starts_with("tier0") || lower.starts_with("gameoverlay")) return "Steam";
|
||||
if (lower.starts_with("amd") || lower.starts_with("nv") || lower.starts_with("igd") || lower.starts_with("d3d")) return "Graphics driver";
|
||||
return module.empty() ? "Thread" : module;
|
||||
}
|
||||
void refresh() {
|
||||
for (auto& [id, thread] : threads_) thread.seen = false;
|
||||
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
||||
if (snapshot == INVALID_HANDLE_VALUE) return;
|
||||
const auto self = GetCurrentProcessId();
|
||||
THREADENTRY32 entry{sizeof(entry)};
|
||||
for (BOOL ok = Thread32First(snapshot, &entry); ok; ok = Thread32Next(snapshot, &entry)) {
|
||||
if (entry.th32OwnerProcessID != self) continue;
|
||||
auto [it, fresh] = threads_.try_emplace(entry.th32ThreadID);
|
||||
it->second.seen = true;
|
||||
if (fresh) {
|
||||
// Full query rights read the start address (for the label); limited ones still time it.
|
||||
it->second.handle = OpenThread(THREAD_QUERY_INFORMATION, FALSE, entry.th32ThreadID);
|
||||
if (!it->second.handle)
|
||||
it->second.handle = OpenThread(THREAD_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ThreadID);
|
||||
if (it->second.handle) {
|
||||
FILETIME created, exited, kernel, user;
|
||||
if (GetThreadTimes(it->second.handle, &created, &exited, &kernel, &user))
|
||||
it->second.last = filetime(kernel) + filetime(user);
|
||||
it->second.label = label_of(it->second.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseHandle(snapshot);
|
||||
for (auto it = threads_.begin(); it != threads_.end();) {
|
||||
if (it->second.seen) { ++it; continue; }
|
||||
if (it->second.handle) CloseHandle(it->second.handle);
|
||||
it = threads_.erase(it);
|
||||
}
|
||||
}
|
||||
public:
|
||||
static std::string label(HANDLE handle) { return label_of(handle); }
|
||||
void sample(Summary& summary) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= next_refresh_) { refresh(); next_refresh_ = now + std::chrono::seconds(3); }
|
||||
FILETIME wall_time;
|
||||
GetSystemTimePreciseAsFileTime(&wall_time);
|
||||
const auto wall = filetime(wall_time);
|
||||
const double elapsed = last_wall_ ? static_cast<double>(wall - last_wall_) : 0.0;
|
||||
FILETIME created, exited, kernel, user;
|
||||
if (GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) {
|
||||
const auto process = filetime(kernel) + filetime(user);
|
||||
if (elapsed > 0 && last_process_) summary.process_cpu_percent = 100.0 * static_cast<double>(process - last_process_) / elapsed;
|
||||
last_process_ = process;
|
||||
}
|
||||
const auto client = detail::client_thread.load(std::memory_order_relaxed);
|
||||
const auto present = detail::present_thread.load(std::memory_order_relaxed);
|
||||
for (auto& [id, thread] : threads_) {
|
||||
if (!thread.handle || !GetThreadTimes(thread.handle, &created, &exited, &kernel, &user)) continue;
|
||||
const auto total = filetime(kernel) + filetime(user);
|
||||
const double percent = elapsed > 0 ? 100.0 * static_cast<double>(total - thread.last) / elapsed : 0.0;
|
||||
thread.last = total;
|
||||
if (percent < 0.5 && id != client && id != present) continue;
|
||||
std::string label = id == client ? "Client update" : id == present ? "Present" : thread.label;
|
||||
summary.threads.push_back({static_cast<std::uint32_t>(id), std::move(label), percent});
|
||||
}
|
||||
last_wall_ = wall;
|
||||
std::ranges::sort(summary.threads, [](const ThreadRow& a, const ThreadRow& b) { return a.cpu_percent > b.cpu_percent; });
|
||||
}
|
||||
~ThreadMonitor() {
|
||||
for (auto& [id, thread] : threads_) if (thread.handle) CloseHandle(thread.handle);
|
||||
}
|
||||
};
|
||||
|
||||
void monitor() {
|
||||
SetThreadDescription(GetCurrentThread(), L"ReSkate profiler");
|
||||
ThreadMonitor threads;
|
||||
auto last = std::chrono::steady_clock::now();
|
||||
// Zone ticks to nanoseconds: the timestamp counter measured against the steady clock over
|
||||
// each summary's second.
|
||||
auto last_ticks = __rdtsc();
|
||||
while (wanted()) {
|
||||
zones_enabled.store(true, std::memory_order_relaxed);
|
||||
Sleep(1000);
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const double seconds = std::max(1e-3, std::chrono::duration<double>(now - last).count());
|
||||
last = now;
|
||||
const auto now_ticks = __rdtsc();
|
||||
const double ms_per_tick = seconds * 1e3 / static_cast<double>(std::max<std::uint64_t>(1, now_ticks - last_ticks));
|
||||
last_ticks = now_ticks;
|
||||
auto next = std::make_shared<Summary>();
|
||||
auto& s = *next;
|
||||
s.seconds = seconds;
|
||||
s.cores = GetActiveProcessorCount(ALL_PROCESSOR_GROUPS);
|
||||
s.client_thread = detail::client_thread.load(std::memory_order_relaxed);
|
||||
s.present_thread = detail::present_thread.load(std::memory_order_relaxed);
|
||||
const auto ms = [](std::uint64_t ns) { return static_cast<double>(ns) / 1e6; };
|
||||
const auto interval = client_interval.take(), frame = client_frame.take(), own = client_own.take();
|
||||
s.client_updates_per_second = static_cast<double>(own.count) / seconds;
|
||||
if (interval.count) {
|
||||
s.client_interval_average_ms = ms(interval.sum) / static_cast<double>(interval.count);
|
||||
s.client_interval_longest_ms = ms(interval.longest);
|
||||
}
|
||||
if (frame.count) {
|
||||
s.client_frame_average_ms = ms(frame.sum) / static_cast<double>(frame.count);
|
||||
s.client_frame_longest_ms = ms(frame.longest);
|
||||
}
|
||||
if (own.count) {
|
||||
s.client_own_average_ms = ms(own.sum) / static_cast<double>(own.count);
|
||||
s.client_own_longest_ms = ms(own.longest);
|
||||
}
|
||||
const auto presents = present_interval.take();
|
||||
s.presents_per_second = static_cast<double>(presents.count) / seconds;
|
||||
if (presents.count) {
|
||||
s.present_interval_average_ms = ms(presents.sum) / static_cast<double>(presents.count);
|
||||
s.present_interval_longest_ms = ms(presents.longest);
|
||||
}
|
||||
double hooks_ms{};
|
||||
for (auto* site = sites.load(std::memory_order_acquire); site; site = site->next) {
|
||||
const auto calls = site->calls.exchange(0, std::memory_order_relaxed);
|
||||
const auto total = static_cast<double>(site->ticks.exchange(0, std::memory_order_relaxed)) * ms_per_tick;
|
||||
const auto longest = static_cast<double>(site->longest.exchange(0, std::memory_order_relaxed)) * ms_per_tick;
|
||||
if (!calls) continue;
|
||||
ZoneRow row{site->name, static_cast<double>(calls) / seconds, total / seconds,
|
||||
total * 1e3 / static_cast<double>(calls), longest};
|
||||
if (std::string_view(site->name).starts_with("hooks/")) hooks_ms += total;
|
||||
// Sites sharing a name (the same work in two hooks) show as one row.
|
||||
const auto same = std::ranges::find(s.zones, row.name, &ZoneRow::name);
|
||||
if (same == s.zones.end()) { s.zones.push_back(std::move(row)); continue; }
|
||||
const auto calls_total = same->calls_per_second + row.calls_per_second;
|
||||
same->average_microseconds = calls_total > 0 ? (same->average_microseconds * same->calls_per_second +
|
||||
row.average_microseconds * row.calls_per_second) / calls_total : 0.0;
|
||||
same->calls_per_second = calls_total;
|
||||
same->milliseconds_per_second += row.milliseconds_per_second;
|
||||
same->longest_milliseconds = std::max(same->longest_milliseconds, row.longest_milliseconds);
|
||||
}
|
||||
if (own.count) s.hooks_milliseconds_per_update = hooks_ms / static_cast<double>(own.count);
|
||||
std::ranges::sort(s.zones, [](const ZoneRow& a, const ZoneRow& b) {
|
||||
return a.milliseconds_per_second > b.milliseconds_per_second;
|
||||
});
|
||||
threads.sample(s);
|
||||
std::lock_guard lock(summary_mutex);
|
||||
latest_summary = std::move(next);
|
||||
}
|
||||
zones_enabled.store(false, std::memory_order_relaxed);
|
||||
monitor_running.store(false, std::memory_order_release);
|
||||
// Something may have asked again between the last check and the flag being cleared.
|
||||
if (wanted()) detail::wake_monitor();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace detail {
|
||||
std::string thread_label(HANDLE thread, std::uint32_t id) {
|
||||
if (id == client_thread.load(std::memory_order_relaxed)) return "Client update";
|
||||
if (id == present_thread.load(std::memory_order_relaxed)) return "Present";
|
||||
return ThreadMonitor::label(thread);
|
||||
}
|
||||
void wake_monitor() noexcept {
|
||||
if (!wanted() || monitor_running.exchange(true, std::memory_order_acq_rel)) return;
|
||||
zones_enabled.store(true, std::memory_order_relaxed);
|
||||
try { std::thread(monitor).detach(); }
|
||||
catch (...) { monitor_running.store(false, std::memory_order_release); }
|
||||
}
|
||||
}
|
||||
|
||||
Site::Site(const char* site_name) noexcept : name(site_name) {
|
||||
auto* head = sites.load(std::memory_order_relaxed);
|
||||
do { next = head; } while (!sites.compare_exchange_weak(head, this, std::memory_order_release, std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
void record_client_update(std::uint64_t own_ns) noexcept {
|
||||
// Known even while off, so a sample can target it straight away.
|
||||
detail::client_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
||||
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
||||
static std::uint64_t last{};
|
||||
const auto now = now_ns();
|
||||
if (last && now > last) {
|
||||
client_interval.add(now - last);
|
||||
rings[static_cast<std::size_t>(Series::client_interval)].push(static_cast<float>(now - last) / 1e6f);
|
||||
}
|
||||
last = now;
|
||||
client_own.add(own_ns);
|
||||
rings[static_cast<std::size_t>(Series::client_own)].push(static_cast<float>(own_ns) / 1e6f);
|
||||
}
|
||||
|
||||
void record_client_frame(std::uint64_t frame_ns) noexcept {
|
||||
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
||||
client_frame.add(frame_ns);
|
||||
rings[static_cast<std::size_t>(Series::client_frame)].push(static_cast<float>(frame_ns) / 1e6f);
|
||||
}
|
||||
|
||||
void record_present() noexcept {
|
||||
detail::present_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
||||
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
||||
static std::uint64_t last{};
|
||||
const auto now = now_ns();
|
||||
if (last && now > last) {
|
||||
present_interval.add(now - last);
|
||||
rings[static_cast<std::size_t>(Series::present_interval)].push(static_cast<float>(now - last) / 1e6f);
|
||||
}
|
||||
last = now;
|
||||
}
|
||||
|
||||
std::size_t history(Series series, std::span<float> out) noexcept {
|
||||
auto& ring = rings[static_cast<std::size_t>(series)];
|
||||
const auto written = ring.written.load(std::memory_order_acquire);
|
||||
const auto count = static_cast<std::size_t>(std::min<std::uint64_t>({written, history_size, out.size()}));
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
out[i] = ring.values[(written - count + i) % history_size].load(std::memory_order_relaxed);
|
||||
return count;
|
||||
}
|
||||
|
||||
void set_hud(bool visible) noexcept { hud_visible.store(visible, std::memory_order_release); detail::wake_monitor(); }
|
||||
bool hud() noexcept { return hud_visible.load(std::memory_order_acquire); }
|
||||
void set_window(bool visible) noexcept { window_visible.store(visible, std::memory_order_release); detail::wake_monitor(); }
|
||||
bool window() noexcept { return window_visible.load(std::memory_order_acquire); }
|
||||
bool active() noexcept { return wanted(); }
|
||||
|
||||
std::shared_ptr<const Summary> summary() noexcept {
|
||||
std::lock_guard lock(summary_mutex);
|
||||
return latest_summary;
|
||||
}
|
||||
} // namespace dingosdk::profiler
|
||||
@@ -0,0 +1,147 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <intrin.h>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ReSkate's performance profiler (Extension/UI/Overlay/perf_overlay.cpp draws it, `perf` console
|
||||
// commands drive it):
|
||||
// * zones: scoped timers on ReSkate's own work, so its cost on the game's threads is visible;
|
||||
// * the client update (the hooked native client tick) and presented frames, timed;
|
||||
// * CPU use per thread of the game, which shows a runaway worker at a glance;
|
||||
// * an in-process stack sampler for one thread, with a report the overlay lists and a file of
|
||||
// collapsed stacks (load it in speedscope.app or flamegraph.pl for a flame graph).
|
||||
// Everything is off until something asks for it: a zone then costs one relaxed atomic load.
|
||||
namespace dingosdk::profiler {
|
||||
|
||||
// ---- zones ----------------------------------------------------------------------------------
|
||||
// A place in ReSkate's code whose time is measured. Declared once (DINGO_PROFILE_ZONE makes a
|
||||
// function-local static), timed on any thread. "a/b" names nest under "a" in the window.
|
||||
// Zones count CPU timestamp ticks (__rdtsc, a few nanoseconds; invariant on every CPU the game
|
||||
// runs on) rather than steady_clock (~25 ns through QueryPerformanceCounter): the hook zones run
|
||||
// ~80,000 times a second, where the clock had cost more than the work it timed.
|
||||
struct Site {
|
||||
explicit Site(const char* name) noexcept;
|
||||
const char* name;
|
||||
std::atomic<std::uint64_t> ticks{0}, calls{0}, longest{0};
|
||||
Site* next{};
|
||||
void add(std::uint64_t elapsed) noexcept {
|
||||
ticks.fetch_add(elapsed, std::memory_order_relaxed);
|
||||
calls.fetch_add(1, std::memory_order_relaxed);
|
||||
auto previous = longest.load(std::memory_order_relaxed);
|
||||
while (elapsed > previous && !longest.compare_exchange_weak(previous, elapsed, std::memory_order_relaxed)) {}
|
||||
}
|
||||
};
|
||||
extern std::atomic<bool> zones_enabled;
|
||||
inline std::uint64_t now_ns() noexcept {
|
||||
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
class Scope {
|
||||
Site* site_;
|
||||
std::uint64_t start_;
|
||||
public:
|
||||
explicit Scope(Site& site) noexcept
|
||||
: site_(zones_enabled.load(std::memory_order_relaxed) ? &site : nullptr), start_(site_ ? __rdtsc() : 0) {}
|
||||
~Scope() { if (site_) site_->add(__rdtsc() - start_); }
|
||||
Scope(const Scope&) = delete;
|
||||
Scope& operator=(const Scope&) = delete;
|
||||
};
|
||||
#define DINGO_PROFILE_CAT2(a, b) a##b
|
||||
#define DINGO_PROFILE_CAT(a, b) DINGO_PROFILE_CAT2(a, b)
|
||||
#define DINGO_PROFILE_ZONE(name) \
|
||||
static ::dingosdk::profiler::Site DINGO_PROFILE_CAT(dingo_profile_site_, __LINE__){name}; \
|
||||
const ::dingosdk::profiler::Scope DINGO_PROFILE_CAT(dingo_profile_scope_, __LINE__){ \
|
||||
DINGO_PROFILE_CAT(dingo_profile_site_, __LINE__)}
|
||||
|
||||
// ---- frame timing ---------------------------------------------------------------------------
|
||||
// ReSkate's client tick (once per client update): its own work, timed.
|
||||
void record_client_update(std::uint64_t own_ns) noexcept;
|
||||
// The game's whole client frame job, which the tick runs inside: the number that has to stay
|
||||
// within the sim budget (16.7 ms at 60 Hz) or the game runs in slow motion.
|
||||
void record_client_frame(std::uint64_t frame_ns) noexcept;
|
||||
// Each presented frame (the overlay's Present hook).
|
||||
void record_present() noexcept;
|
||||
enum class Series { client_interval, client_frame, client_own, present_interval };
|
||||
// The most recent values in milliseconds, oldest first; returns how many were written.
|
||||
std::size_t history(Series series, std::span<float> out) noexcept;
|
||||
|
||||
// ---- what is on ------------------------------------------------------------------------------
|
||||
// The HUD and window are the overlay's (both start off each launch); turning either on starts
|
||||
// the timing and thread monitor.
|
||||
void set_hud(bool visible) noexcept;
|
||||
bool hud() noexcept;
|
||||
void set_window(bool visible) noexcept;
|
||||
bool window() noexcept;
|
||||
// Whether anything wants the profiler running (HUD, window or a sample).
|
||||
bool active() noexcept;
|
||||
|
||||
// ---- once-a-second summary ---------------------------------------------------------------------
|
||||
struct ZoneRow {
|
||||
std::string name;
|
||||
double calls_per_second{}, milliseconds_per_second{}, average_microseconds{}, longest_milliseconds{};
|
||||
};
|
||||
struct ThreadRow {
|
||||
std::uint32_t id{};
|
||||
std::string label;
|
||||
double cpu_percent{};
|
||||
};
|
||||
struct Summary {
|
||||
double seconds{}; // the window the rates cover
|
||||
double client_updates_per_second{}, client_interval_average_ms{}, client_interval_longest_ms{};
|
||||
double client_frame_average_ms{}, client_frame_longest_ms{}; // the game's client frame job
|
||||
double client_own_average_ms{}, client_own_longest_ms{};
|
||||
double hooks_milliseconds_per_update{}; // zones named "hooks/..." per client update
|
||||
double presents_per_second{}, present_interval_average_ms{}, present_interval_longest_ms{};
|
||||
double process_cpu_percent{}; // 100 = one whole core
|
||||
unsigned cores{};
|
||||
std::uint32_t client_thread{}, present_thread{};
|
||||
std::vector<ZoneRow> zones; // busiest first
|
||||
std::vector<ThreadRow> threads; // busiest first
|
||||
};
|
||||
std::shared_ptr<const Summary> summary() noexcept;
|
||||
|
||||
// ---- the stack sampler -------------------------------------------------------------------------
|
||||
// `all` samples every thread that was busy (3% of a core or more) when the sample started, and
|
||||
// reports where the process's CPU work went; time spent waiting in the kernel is left out.
|
||||
enum class Target { client, present, thread, all };
|
||||
struct SampleRequest {
|
||||
Target target = Target::client;
|
||||
std::uint32_t thread_id{}; // for Target::thread
|
||||
double seconds = 10;
|
||||
unsigned rate = 1000; // samples per second
|
||||
};
|
||||
struct SampledFunction {
|
||||
std::string name; // "Skate 0x14xxxxxxx [label]" or "module!symbol"
|
||||
std::uint64_t address{}; // function start; Skate.exe ones as Ghidra addresses
|
||||
std::uint32_t self{}, total{}; // samples with it on top / anywhere on the stack
|
||||
};
|
||||
struct SampleReport {
|
||||
bool running{};
|
||||
double progress{}; // 0..1 while running
|
||||
std::string target;
|
||||
std::uint32_t thread_id{};
|
||||
std::uint32_t samples{}, rate{};
|
||||
double seconds{};
|
||||
double busy_percent{}; // samples not waiting in the kernel
|
||||
std::vector<SampledFunction> functions; // by self, then total
|
||||
std::vector<ThreadRow> threads; // Target::all: each thread's share of the busy samples
|
||||
std::filesystem::path report, folded; // written next to ReSkate.log
|
||||
std::string error;
|
||||
};
|
||||
// Starts a sample on its own thread; false (with the reason) if one is running or the target
|
||||
// thread is unknown.
|
||||
bool start_sampling(const SampleRequest& request, std::string& error);
|
||||
void stop_sampling() noexcept;
|
||||
std::shared_ptr<const SampleReport> sample_report() noexcept;
|
||||
|
||||
// Engine function names for the sampler's report, by RVA (Engine/Game/Build/<build>/profiler.h,
|
||||
// plus any "<rva or 0x14 address> <name>" lines in ReSkate.labels.tsv next to Skate.exe).
|
||||
struct Label { std::uintptr_t rva; const char* name; };
|
||||
void set_engine_labels(std::span<const Label> labels) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include "profiler.h"
|
||||
#include <Windows.h>
|
||||
|
||||
namespace dingosdk::profiler::detail {
|
||||
// Threads the summary and the sampler name: the one running the client update, and the one
|
||||
// presenting frames.
|
||||
extern std::atomic<std::uint32_t> client_thread, present_thread;
|
||||
// True while a sample runs; keeps the monitor (and so the zones) alive.
|
||||
extern std::atomic<bool> sampling;
|
||||
// Starts the once-a-second monitor if anything wants it.
|
||||
void wake_monitor() noexcept;
|
||||
// What a thread is, for reports: its description, or what its start address belongs to.
|
||||
std::string thread_label(HANDLE thread, std::uint32_t id);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
#include "profiler_internal.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <DbgHelp.h>
|
||||
#include <TlHelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <functional>
|
||||
|
||||
// The stack sampler. A sample suspends the target thread, takes its context and walks its stack,
|
||||
// then resumes it. While the target is suspended this thread must not take any lock the target
|
||||
// might hold -- the heap, the loader, DbgHelp -- or both stop for good. So the walk uses only
|
||||
// memory prepared beforehand: each module's own unwind table (found by binary search, not
|
||||
// RtlLookupFunctionEntry, which locks) and RtlVirtualUnwind, which only reads. Names are looked
|
||||
// up after sampling ends.
|
||||
namespace dingosdk::profiler {
|
||||
namespace {
|
||||
constexpr std::uint32_t max_depth = 96;
|
||||
constexpr std::uintptr_t ghidra_image_base = 0x140000000;
|
||||
|
||||
struct Module {
|
||||
std::uintptr_t base{}, end{};
|
||||
const RUNTIME_FUNCTION* functions{};
|
||||
std::uint32_t count{};
|
||||
std::wstring path;
|
||||
std::string name;
|
||||
bool exe{};
|
||||
};
|
||||
struct Modules {
|
||||
std::vector<Module> list; // sorted by base
|
||||
const Module* find(std::uintptr_t pc) const noexcept {
|
||||
std::size_t lo = 0, hi = list.size();
|
||||
while (lo < hi) {
|
||||
const auto mid = (lo + hi) / 2;
|
||||
if (list[mid].end <= pc) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
return lo < list.size() && list[lo].base <= pc && pc < list[lo].end ? &list[lo] : nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
std::string narrow(std::wstring_view text) {
|
||||
std::string result;
|
||||
for (const auto c : text) result.push_back(c < 128 ? static_cast<char>(c) : '?');
|
||||
return result;
|
||||
}
|
||||
|
||||
Modules snapshot_modules() {
|
||||
Modules result;
|
||||
std::vector<HMODULE> handles(1024);
|
||||
DWORD needed{};
|
||||
if (!EnumProcessModulesEx(GetCurrentProcess(), handles.data(), static_cast<DWORD>(handles.size() * sizeof(HMODULE)),
|
||||
&needed, LIST_MODULES_ALL)) return result;
|
||||
handles.resize(std::min<std::size_t>(handles.size(), needed / sizeof(HMODULE)));
|
||||
const auto exe = GetModuleHandleW(nullptr);
|
||||
for (const auto handle : handles) {
|
||||
MODULEINFO info{};
|
||||
if (!GetModuleInformation(GetCurrentProcess(), handle, &info, sizeof(info))) continue;
|
||||
Module module;
|
||||
module.base = reinterpret_cast<std::uintptr_t>(info.lpBaseOfDll);
|
||||
module.end = module.base + info.SizeOfImage;
|
||||
module.exe = handle == exe;
|
||||
wchar_t path[MAX_PATH]{};
|
||||
if (GetModuleFileNameW(handle, path, MAX_PATH)) {
|
||||
module.path = path;
|
||||
const auto slash = module.path.find_last_of(L"\\/");
|
||||
module.name = narrow(slash == std::wstring::npos ? module.path : module.path.substr(slash + 1));
|
||||
}
|
||||
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(module.base);
|
||||
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS64*>(module.base + dos->e_lfanew);
|
||||
if (dos->e_magic == IMAGE_DOS_SIGNATURE && nt->Signature == IMAGE_NT_SIGNATURE &&
|
||||
nt->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC &&
|
||||
nt->OptionalHeader.NumberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_EXCEPTION) {
|
||||
const auto& directory = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
|
||||
if (directory.VirtualAddress && directory.Size && directory.VirtualAddress + directory.Size <= info.SizeOfImage) {
|
||||
module.functions = reinterpret_cast<const RUNTIME_FUNCTION*>(module.base + directory.VirtualAddress);
|
||||
module.count = directory.Size / sizeof(RUNTIME_FUNCTION);
|
||||
}
|
||||
}
|
||||
result.list.push_back(std::move(module));
|
||||
}
|
||||
std::ranges::sort(result.list, {}, &Module::base);
|
||||
return result;
|
||||
}
|
||||
|
||||
const RUNTIME_FUNCTION* lookup(const Module& module, std::uintptr_t pc) noexcept {
|
||||
if (!module.functions) return nullptr;
|
||||
const auto rva = static_cast<std::uint32_t>(pc - module.base);
|
||||
std::uint32_t lo = 0, hi = module.count;
|
||||
while (lo < hi) {
|
||||
const auto mid = (lo + hi) / 2;
|
||||
if (module.functions[mid].EndAddress <= rva) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
return lo < module.count && module.functions[lo].BeginAddress <= rva ? &module.functions[lo] : nullptr;
|
||||
}
|
||||
|
||||
// Unwinds a suspended thread's context into frames. No allocation, no locks; a fault on a
|
||||
// corrupt stack just ends the walk.
|
||||
std::uint32_t walk(const Modules& modules, CONTEXT& context, std::uint64_t* frames) noexcept {
|
||||
std::uint32_t depth = 0;
|
||||
__try {
|
||||
while (depth < max_depth && context.Rip) {
|
||||
const auto pc = context.Rip, sp = context.Rsp;
|
||||
frames[depth++] = pc;
|
||||
const auto* module = modules.find(pc);
|
||||
if (!module) break;
|
||||
// Past the first frame the PC is a return address, which can sit just after the end of
|
||||
// its function: look it up one byte back.
|
||||
const auto* entry = lookup(*module, depth > 1 ? pc - 1 : pc);
|
||||
if (!entry) {
|
||||
// A leaf function: no frame, the return address is on top of the stack.
|
||||
context.Rip = *reinterpret_cast<const DWORD64*>(context.Rsp);
|
||||
context.Rsp += 8;
|
||||
} else {
|
||||
void* handler_data{};
|
||||
DWORD64 establisher{};
|
||||
RtlVirtualUnwind(UNW_FLAG_NHANDLER, module->base, pc, const_cast<PRUNTIME_FUNCTION>(entry), &context,
|
||||
&handler_data, &establisher, nullptr);
|
||||
}
|
||||
if (context.Rsp <= sp) break; // a stack only unwinds upwards
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// The function a frame belongs to: its primary unwind entry (chained entries describe parts of
|
||||
// the same function) -- the start Ghidra shows too.
|
||||
std::uintptr_t function_start(const Modules& modules, std::uintptr_t pc, const Module** owner) noexcept {
|
||||
const auto* module = modules.find(pc);
|
||||
if (owner) *owner = module;
|
||||
if (!module) return pc;
|
||||
const auto* entry = lookup(*module, pc);
|
||||
for (int guard = 0; entry && guard < 32; ++guard) {
|
||||
const auto* info = reinterpret_cast<const std::uint8_t*>(module->base + entry->UnwindData);
|
||||
if (!(info[0] >> 3 & UNW_FLAG_CHAININFO)) break;
|
||||
const auto codes = info[2];
|
||||
entry = reinterpret_cast<const RUNTIME_FUNCTION*>(info + 4 + ((codes + 1) & ~1) * 2);
|
||||
}
|
||||
return entry ? module->base + entry->BeginAddress : pc;
|
||||
}
|
||||
|
||||
// ---- names ----
|
||||
std::mutex labels_mutex;
|
||||
std::vector<Label> engine_labels;
|
||||
|
||||
std::unordered_map<std::uintptr_t, std::string> load_labels(const Modules& modules) {
|
||||
std::unordered_map<std::uintptr_t, std::string> result;
|
||||
{
|
||||
std::lock_guard lock(labels_mutex);
|
||||
for (const auto& label : engine_labels) result[label.rva] = label.name;
|
||||
}
|
||||
// ReSkate.labels.tsv next to Skate.exe: "<RVA or Ghidra address> <name>" per line, so
|
||||
// functions named while reading a profile show up by name in the next one.
|
||||
for (const auto& module : modules.list) {
|
||||
if (!module.exe) continue;
|
||||
const auto folder = std::filesystem::path(module.path).parent_path();
|
||||
std::ifstream file(folder / L"ReSkate.labels.tsv");
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
const auto first = line.find_first_not_of(" \t");
|
||||
if (first == std::string::npos || line[first] == '#') continue;
|
||||
const auto gap = line.find_first_of(" \t", first);
|
||||
if (gap == std::string::npos) continue;
|
||||
const auto rest = line.find_first_not_of(" \t", gap);
|
||||
if (rest == std::string::npos) continue;
|
||||
std::uintptr_t address{};
|
||||
try { address = std::stoull(line.substr(first, gap - first), nullptr, 16); } catch (...) { continue; }
|
||||
if (address >= ghidra_image_base) address -= ghidra_image_base;
|
||||
auto name = line.substr(rest);
|
||||
while (!name.empty() && (name.back() == '\r' || name.back() == ' ' || name.back() == '\t')) name.pop_back();
|
||||
if (!name.empty()) result[address] = name;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// DbgHelp is single-threaded and the process may use it elsewhere (a crash handler): a private
|
||||
// session under a handle of our own, used only from the sampler thread, under this lock.
|
||||
std::mutex symbols_mutex;
|
||||
const HANDLE symbol_session = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(0x5245534b)); // "RESK"
|
||||
bool symbols_ready{};
|
||||
std::unordered_set<std::uintptr_t> symbols_loaded;
|
||||
|
||||
std::string symbol_name(const Module& module, std::uintptr_t address) {
|
||||
std::lock_guard lock(symbols_mutex);
|
||||
if (!symbols_ready) {
|
||||
SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_NO_PROMPTS | SYMOPT_FAIL_CRITICAL_ERRORS);
|
||||
symbols_ready = SymInitializeW(symbol_session, nullptr, FALSE) != FALSE;
|
||||
}
|
||||
if (symbols_ready && symbols_loaded.insert(module.base).second)
|
||||
SymLoadModuleExW(symbol_session, nullptr, module.path.c_str(), nullptr, module.base,
|
||||
static_cast<DWORD>(module.end - module.base), nullptr, 0);
|
||||
alignas(SYMBOL_INFO) char buffer[sizeof(SYMBOL_INFO) + 512]{};
|
||||
auto* info = reinterpret_cast<SYMBOL_INFO*>(buffer);
|
||||
info->SizeOfStruct = sizeof(SYMBOL_INFO);
|
||||
info->MaxNameLen = 511;
|
||||
DWORD64 displacement{};
|
||||
if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0])
|
||||
return module.name + "!" + info->Name;
|
||||
return std::format("{}+0x{:x}", module.name, address - module.base);
|
||||
}
|
||||
|
||||
bool waiting(std::string_view name) {
|
||||
constexpr std::string_view waits[] = {"WaitFor", "DelayExecution", "RemoveIoCompletion", "YieldExecution",
|
||||
"SignalAndWait", "WaitForAlertByThreadId", "WaitForWorkViaWorkerFactory", "ReplyWaitReceivePort"};
|
||||
if (!name.starts_with("ntdll.dll!") && !name.starts_with("win32u.dll!")) return false;
|
||||
return std::ranges::any_of(waits, [&](std::string_view wait) { return name.find(wait) != std::string_view::npos; });
|
||||
}
|
||||
|
||||
// ---- state ----
|
||||
std::mutex report_mutex;
|
||||
std::shared_ptr<const SampleReport> latest_report = std::make_shared<SampleReport>();
|
||||
std::atomic<bool> stop_requested{false};
|
||||
|
||||
void publish(SampleReport report) {
|
||||
std::lock_guard lock(report_mutex);
|
||||
latest_report = std::make_shared<SampleReport>(std::move(report));
|
||||
}
|
||||
|
||||
std::string target_name(Target target, std::uint32_t id) {
|
||||
switch (target) {
|
||||
case Target::client: return "client update";
|
||||
case Target::present: return "present";
|
||||
case Target::all: return "all busy threads";
|
||||
default: return std::format("thread {}", id);
|
||||
}
|
||||
}
|
||||
|
||||
std::string sanitize(std::string text) {
|
||||
for (auto& c : text) if (c == ';' || c == '\n' || c == '\r') c = ' ';
|
||||
return text;
|
||||
}
|
||||
|
||||
struct Sampled { HANDLE handle{}; std::uint32_t id{}; std::string label; };
|
||||
// One sample: where its frames start in the pool, how many, and which thread it came from.
|
||||
struct Record { std::uint32_t offset{}; std::uint16_t thread{}; std::uint8_t depth{}; };
|
||||
// The pool holds at most this many frames (64 MiB); a sample that no longer fits ends the run.
|
||||
constexpr std::size_t pool_limit = 8u << 20;
|
||||
|
||||
void run(SampleRequest request, std::vector<Sampled> threads) {
|
||||
SetThreadDescription(GetCurrentThread(), L"ReSkate sampler");
|
||||
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
|
||||
const bool all = request.target == Target::all;
|
||||
SampleReport report;
|
||||
report.running = true;
|
||||
report.target = target_name(request.target, threads.front().id);
|
||||
report.thread_id = all ? 0 : threads.front().id;
|
||||
report.rate = request.rate;
|
||||
const auto modules = snapshot_modules();
|
||||
const auto wanted = static_cast<std::size_t>(request.seconds * request.rate) * threads.size() + 16;
|
||||
const auto capacity = std::min<std::size_t>(wanted, pool_limit / 16);
|
||||
std::vector<std::uint64_t> pool;
|
||||
std::vector<Record> records;
|
||||
const auto close = [&] { for (const auto& t : threads) CloseHandle(t.handle); };
|
||||
try {
|
||||
pool.resize(std::min(pool_limit, capacity * 48 + max_depth));
|
||||
records.reserve(capacity);
|
||||
} catch (...) {
|
||||
report.running = false;
|
||||
report.error = "Not enough memory for the sample buffer.";
|
||||
publish(std::move(report));
|
||||
close();
|
||||
detail::sampling.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
publish(report);
|
||||
|
||||
const HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
||||
const auto period = std::chrono::nanoseconds(1'000'000'000 / request.rate);
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const auto finish = start + std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(request.seconds));
|
||||
auto next = start;
|
||||
auto next_progress = start;
|
||||
std::size_t used = 0;
|
||||
std::vector<char> ended(threads.size());
|
||||
bool full = false;
|
||||
while (!full && !stop_requested.load(std::memory_order_acquire)) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= finish) break;
|
||||
bool any = false;
|
||||
for (std::size_t t = 0; t < threads.size(); ++t) {
|
||||
if (ended[t]) continue;
|
||||
if (records.size() == capacity || pool.size() - used < max_depth) { full = true; break; }
|
||||
// Nothing between Suspend and Resume may allocate or lock: the frames go straight
|
||||
// into the pool, and the record is added after the thread runs again.
|
||||
if (SuspendThread(threads[t].handle) == static_cast<DWORD>(-1)) { ended[t] = 1; continue; }
|
||||
any = true;
|
||||
alignas(16) CONTEXT context{};
|
||||
context.ContextFlags = CONTEXT_FULL;
|
||||
std::uint32_t depth = 0;
|
||||
if (GetThreadContext(threads[t].handle, &context)) depth = walk(modules, context, pool.data() + used);
|
||||
ResumeThread(threads[t].handle);
|
||||
if (!depth) continue;
|
||||
records.push_back({static_cast<std::uint32_t>(used), static_cast<std::uint16_t>(t), static_cast<std::uint8_t>(depth)});
|
||||
used += depth;
|
||||
}
|
||||
if (!any && !full) { report.error = "The thread has ended."; break; }
|
||||
if (now >= next_progress) {
|
||||
next_progress = now + std::chrono::milliseconds(250);
|
||||
report.progress = std::chrono::duration<double>(now - start).count() / request.seconds;
|
||||
report.samples = static_cast<std::uint32_t>(records.size());
|
||||
publish(report);
|
||||
}
|
||||
next += period;
|
||||
const auto wait = next - std::chrono::steady_clock::now();
|
||||
if (wait > std::chrono::microseconds(50)) {
|
||||
if (timer) {
|
||||
LARGE_INTEGER due{};
|
||||
due.QuadPart = -std::max<long long>(1, std::chrono::duration_cast<std::chrono::nanoseconds>(wait).count() / 100);
|
||||
if (SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE)) WaitForSingleObject(timer, 100);
|
||||
} else {
|
||||
Sleep(1);
|
||||
}
|
||||
} else if (wait < -period * 4) {
|
||||
next = std::chrono::steady_clock::now(); // fell behind: don't burst
|
||||
}
|
||||
}
|
||||
if (timer) CloseHandle(timer);
|
||||
close();
|
||||
report.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
report.progress = 1;
|
||||
|
||||
// ---- aggregate: by function, and whole stacks for the flame graph ----
|
||||
try {
|
||||
struct Counts { std::uint32_t self{}, total{}; const Module* module{}; };
|
||||
std::unordered_map<std::uintptr_t, Counts> functions;
|
||||
std::vector<std::uintptr_t> stack;
|
||||
std::unordered_map<std::string, std::uint32_t> folded;
|
||||
const auto labels = load_labels(modules);
|
||||
std::unordered_map<std::uintptr_t, std::string> names;
|
||||
std::unordered_map<std::uintptr_t, const Module*> owners;
|
||||
const auto name_of = [&](std::uintptr_t key, const Module* module) -> const std::string& {
|
||||
auto it = names.find(key);
|
||||
if (it != names.end()) return it->second;
|
||||
std::string name;
|
||||
if (!module) name = std::format("0x{:x}", key);
|
||||
else if (module->exe) {
|
||||
const auto rva = key - module->base;
|
||||
const auto label = labels.find(rva);
|
||||
name = std::format("Skate 0x{:x}", ghidra_image_base + rva);
|
||||
if (label != labels.end()) name += " " + label->second;
|
||||
} else name = symbol_name(*module, key);
|
||||
return names.emplace(key, std::move(name)).first->second;
|
||||
};
|
||||
std::vector<std::uint32_t> busy_by_thread(threads.size());
|
||||
std::uint32_t idle{}, counted{};
|
||||
for (const auto& record : records) {
|
||||
const auto* sample = pool.data() + record.offset;
|
||||
stack.clear();
|
||||
for (std::uint32_t f = 0; f < record.depth; ++f) {
|
||||
const Module* module{};
|
||||
stack.push_back(function_start(modules, f ? sample[f] - 1 : sample[f], &module));
|
||||
owners[stack.back()] = module;
|
||||
}
|
||||
const bool waits = !stack.empty() && waiting(name_of(stack.front(), owners[stack.front()]));
|
||||
if (waits) ++idle; else ++busy_by_thread[record.thread];
|
||||
// Across threads, a profile of the work: waiting threads are not where CPU went.
|
||||
if (all && waits) continue;
|
||||
++counted;
|
||||
for (std::size_t f = 0; f < stack.size(); ++f) {
|
||||
auto& counts = functions[stack[f]];
|
||||
counts.module = owners[stack[f]];
|
||||
if (!f) ++counts.self;
|
||||
const auto here = stack.begin() + static_cast<std::ptrdiff_t>(f);
|
||||
if (std::find(stack.begin(), here, stack[f]) == here) ++counts.total;
|
||||
}
|
||||
std::string line = all ? sanitize(std::format("thread {} {}", threads[record.thread].id, threads[record.thread].label))
|
||||
: std::string{};
|
||||
for (auto it = stack.rbegin(); it != stack.rend(); ++it) {
|
||||
if (!line.empty()) line += ';';
|
||||
line += sanitize(name_of(*it, owners[*it]));
|
||||
}
|
||||
++folded[line];
|
||||
}
|
||||
const auto total = records.size();
|
||||
report.samples = counted;
|
||||
report.busy_percent = total ? 100.0 * static_cast<double>(total - idle) / static_cast<double>(total) : 0.0;
|
||||
if (all) {
|
||||
const auto busy_total = std::max<std::uint32_t>(1, counted);
|
||||
for (std::size_t t = 0; t < threads.size(); ++t)
|
||||
if (busy_by_thread[t])
|
||||
report.threads.push_back({threads[t].id, threads[t].label, 100.0 * busy_by_thread[t] / busy_total});
|
||||
std::ranges::sort(report.threads, [](const ThreadRow& a, const ThreadRow& b) { return a.cpu_percent > b.cpu_percent; });
|
||||
}
|
||||
for (const auto& [key, counts] : functions)
|
||||
report.functions.push_back({name_of(key, counts.module),
|
||||
counts.module && counts.module->exe ? ghidra_image_base + (key - counts.module->base) : key,
|
||||
counts.self, counts.total});
|
||||
std::ranges::sort(report.functions, [](const SampledFunction& a, const SampledFunction& b) {
|
||||
return a.self != b.self ? a.self > b.self : a.total > b.total;
|
||||
});
|
||||
|
||||
// ---- files next to ReSkate.log ----
|
||||
const auto directory = logging::status().directory;
|
||||
SYSTEMTIME now{};
|
||||
GetLocalTime(&now);
|
||||
const auto stem = std::format("profile-{:04}{:02}{:02}-{:02}{:02}{:02}-{}", now.wYear, now.wMonth, now.wDay,
|
||||
now.wHour, now.wMinute, now.wSecond, request.target == Target::client ? "client" :
|
||||
request.target == Target::present ? "present" : all ? "all" : std::to_string(threads.front().id));
|
||||
report.report = directory / (stem + ".txt");
|
||||
report.folded = directory / (stem + ".folded");
|
||||
const double per = static_cast<double>(std::max<std::uint32_t>(1, counted));
|
||||
{
|
||||
std::ofstream out(report.report);
|
||||
if (all) {
|
||||
out << std::format("ReSkate profile: all busy threads ({}), {:.1f} s, {} samples at {} Hz per thread\n",
|
||||
threads.size(), report.seconds, total, request.rate);
|
||||
out << std::format("Busy {:.1f}% of samples; the {} busy ones are profiled below (waiting left out).\n",
|
||||
report.busy_percent, counted);
|
||||
out << "\n Share Thread\n";
|
||||
for (const auto& t : report.threads) out << std::format("{:5.1f}% {} {}\n", t.cpu_percent, t.id, t.label);
|
||||
out << "\n";
|
||||
} else {
|
||||
out << std::format("ReSkate profile: {} (thread {}), {:.1f} s, {} samples at {} Hz\n", report.target,
|
||||
threads.front().id, report.seconds, total, request.rate);
|
||||
out << std::format("Busy {:.1f}% (the rest waiting in the kernel)\n", report.busy_percent);
|
||||
}
|
||||
out << "Skate.exe functions are Ghidra addresses (image base 0x140000000). Name them in\n"
|
||||
"ReSkate.labels.tsv next to Skate.exe (\"<address> <name>\" per line) for the next report.\n"
|
||||
"Flame graph: open the .folded file at https://www.speedscope.app\n\n";
|
||||
out << " Self Total Function\n";
|
||||
for (const auto& f : report.functions) {
|
||||
if (f.total * 1000.0 < per) continue; // under 0.1%
|
||||
out << std::format("{:5.1f}% {:5.1f}% {}\n", 100.0 * f.self / per, 100.0 * f.total / per, f.name);
|
||||
}
|
||||
}
|
||||
{
|
||||
std::ofstream out(report.folded);
|
||||
for (const auto& [line, n] : folded) out << line << ' ' << n << '\n';
|
||||
}
|
||||
if (report.functions.size() > 400) report.functions.resize(400);
|
||||
logging::log(logging::Level::info, logging::Channel::diagnostics, "Profiler: sampled {} for {:.1f} s ({} samples); report {}",
|
||||
report.target, report.seconds, total, narrow(report.report.wstring()));
|
||||
} catch (const std::exception& e) {
|
||||
report.error = std::string("Report failed: ") + e.what();
|
||||
}
|
||||
report.running = false;
|
||||
publish(std::move(report));
|
||||
detail::sampling.store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
std::uint64_t cpu_time(HANDLE thread) {
|
||||
FILETIME created, exited, kernel, user;
|
||||
if (!GetThreadTimes(thread, &created, &exited, &kernel, &user)) return 0;
|
||||
const auto value = [](const FILETIME& f) { return (static_cast<std::uint64_t>(f.dwHighDateTime) << 32) | f.dwLowDateTime; };
|
||||
return value(kernel) + value(user);
|
||||
}
|
||||
|
||||
// The process's threads that used 3% of a core or more over a quarter second (at most 48),
|
||||
// other than ReSkate's own profiler threads.
|
||||
std::vector<Sampled> busy_threads() {
|
||||
std::vector<Sampled> candidates;
|
||||
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
||||
if (snapshot == INVALID_HANDLE_VALUE) return {};
|
||||
THREADENTRY32 entry{sizeof(entry)};
|
||||
const auto self = GetCurrentProcessId(), me = GetCurrentThreadId();
|
||||
for (BOOL ok = Thread32First(snapshot, &entry); ok; ok = Thread32Next(snapshot, &entry)) {
|
||||
if (entry.th32OwnerProcessID != self || entry.th32ThreadID == me) continue;
|
||||
const HANDLE handle = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION, FALSE,
|
||||
entry.th32ThreadID);
|
||||
if (handle) candidates.push_back({handle, entry.th32ThreadID, {}});
|
||||
}
|
||||
CloseHandle(snapshot);
|
||||
std::vector<std::uint64_t> before(candidates.size());
|
||||
for (std::size_t i = 0; i < candidates.size(); ++i) before[i] = cpu_time(candidates[i].handle);
|
||||
Sleep(250);
|
||||
std::vector<std::pair<std::uint64_t, std::size_t>> used;
|
||||
for (std::size_t i = 0; i < candidates.size(); ++i) used.push_back({cpu_time(candidates[i].handle) - before[i], i});
|
||||
std::ranges::sort(used, std::greater<>{});
|
||||
std::vector<Sampled> result;
|
||||
for (const auto& [time, i] : used) {
|
||||
auto& c = candidates[i];
|
||||
PWSTR description{};
|
||||
bool ours = false;
|
||||
if (SUCCEEDED(GetThreadDescription(c.handle, &description)) && description) {
|
||||
ours = std::wstring_view(description).starts_with(L"ReSkate ");
|
||||
LocalFree(description);
|
||||
}
|
||||
// 3% of a core over 250 ms, in 100 ns units.
|
||||
if (ours || time < 75'000 || result.size() == 48) { CloseHandle(c.handle); continue; }
|
||||
c.label = detail::thread_label(c.handle, c.id);
|
||||
result.push_back(std::move(c));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool start_sampling(const SampleRequest& request, std::string& error) {
|
||||
if (detail::sampling.exchange(true, std::memory_order_acq_rel)) { error = "A sample is already running."; return false; }
|
||||
auto bounded = request;
|
||||
bounded.seconds = std::clamp(bounded.seconds, 0.5, 60.0);
|
||||
bounded.rate = std::clamp(bounded.rate, 50u, 4000u);
|
||||
stop_requested.store(false, std::memory_order_release);
|
||||
detail::wake_monitor();
|
||||
try {
|
||||
// Choosing the busy threads takes a quarter second: done on the sampler's own thread.
|
||||
std::thread([bounded] {
|
||||
std::vector<Sampled> threads;
|
||||
std::string failure;
|
||||
if (bounded.target == Target::all) {
|
||||
threads = busy_threads();
|
||||
if (threads.empty()) failure = "No busy threads found.";
|
||||
} else {
|
||||
const std::uint32_t id = bounded.target == Target::client ? detail::client_thread.load()
|
||||
: bounded.target == Target::present ? detail::present_thread.load() : bounded.thread_id;
|
||||
const HANDLE handle = id ? OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION,
|
||||
FALSE, id) : nullptr;
|
||||
if (!id) failure = bounded.target == Target::thread ? "No thread id given." :
|
||||
"That thread has not run yet; try again once the game is up.";
|
||||
else if (!handle) failure = std::format("Cannot open thread {} (error {}).", id, GetLastError());
|
||||
else threads.push_back({handle, id, detail::thread_label(handle, id)});
|
||||
}
|
||||
if (!failure.empty()) {
|
||||
SampleReport report;
|
||||
report.error = failure;
|
||||
publish(std::move(report));
|
||||
detail::sampling.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
run(bounded, std::move(threads));
|
||||
}).detach();
|
||||
} catch (...) {
|
||||
detail::sampling.store(false, std::memory_order_release);
|
||||
error = "Cannot start the sampler thread.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop_sampling() noexcept { stop_requested.store(true, std::memory_order_release); }
|
||||
|
||||
std::shared_ptr<const SampleReport> sample_report() noexcept {
|
||||
std::lock_guard lock(report_mutex);
|
||||
return latest_report;
|
||||
}
|
||||
|
||||
void set_engine_labels(std::span<const Label> labels) noexcept {
|
||||
try {
|
||||
std::lock_guard lock(labels_mutex);
|
||||
engine_labels.assign(labels.begin(), labels.end());
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
#include "save_database.h"
|
||||
#include <sqlite3.h>
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::storage {
|
||||
namespace {
|
||||
constexpr int application_id = 0x52534b54; // RSKT
|
||||
constexpr std::size_t max_bytes = 64 * 1024 * 1024, max_record = 4 * 1024 * 1024, max_nodes = 262144;
|
||||
void check(bool ok, const char* message) { if (!ok) throw std::runtime_error(message); }
|
||||
void sql_check(int code, sqlite3* db) {
|
||||
if (code != SQLITE_OK) throw std::runtime_error(std::string("Save database: ") + sqlite3_errmsg(db));
|
||||
}
|
||||
void execute(sqlite3* db, const char* sql) { sql_check(sqlite3_exec(db, sql, nullptr, nullptr, nullptr), db); }
|
||||
class Connection {
|
||||
public:
|
||||
sqlite3* db{};
|
||||
Connection(const std::filesystem::path& path, int flags) {
|
||||
const auto name = path.u8string();
|
||||
const auto code = sqlite3_open_v2(reinterpret_cast<const char*>(name.c_str()), &db, flags | SQLITE_OPEN_FULLMUTEX, nullptr);
|
||||
if (code != SQLITE_OK) {
|
||||
const std::string error = db ? sqlite3_errmsg(db) : "Cannot allocate SQLite connection";
|
||||
if (db) sqlite3_close(db);
|
||||
db = nullptr; throw std::runtime_error("Save database: " + error);
|
||||
}
|
||||
sqlite3_extended_result_codes(db, 1);
|
||||
sqlite3_limit(db, SQLITE_LIMIT_LENGTH, static_cast<int>(max_record + 8192));
|
||||
sqlite3_limit(db, SQLITE_LIMIT_SQL_LENGTH, 16384);
|
||||
sqlite3_db_config(db, SQLITE_DBCONFIG_DEFENSIVE, 1, nullptr);
|
||||
sqlite3_db_config(db, SQLITE_DBCONFIG_TRUSTED_SCHEMA, 0, nullptr);
|
||||
// ReSkate owns its writers. External contention fails promptly without
|
||||
// pausing a native callback for a long database busy timeout.
|
||||
sqlite3_busy_timeout(db, 0);
|
||||
}
|
||||
~Connection() { if (db) sqlite3_close(db); }
|
||||
Connection(const Connection&) = delete;
|
||||
Connection& operator=(const Connection&) = delete;
|
||||
};
|
||||
class Statement {
|
||||
public:
|
||||
sqlite3_stmt* statement{};
|
||||
sqlite3* db{};
|
||||
Statement(sqlite3* connection, const char* sql) : db(connection) {
|
||||
sql_check(sqlite3_prepare_v2(db, sql, -1, &statement, nullptr), db);
|
||||
}
|
||||
~Statement() { sqlite3_finalize(statement); }
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
void bind(int index, std::string_view value) {
|
||||
check(value.size() <= max_record, "Save database record exceeds limit");
|
||||
sql_check(sqlite3_bind_text(statement, index, value.data(), static_cast<int>(value.size()), SQLITE_TRANSIENT), db);
|
||||
}
|
||||
void bind(int index, const Json& value) {
|
||||
if (value.is_string()) bind(index, std::string_view(value.string()));
|
||||
else if (value.is_null()) sql_check(sqlite3_bind_null(statement, index), db);
|
||||
else if (value.is_boolean()) sql_check(sqlite3_bind_int(statement, index, value.get<bool>() ? 1 : 0), db);
|
||||
else if (value.is_number_integer()) {
|
||||
check(!value.is_number_unsigned() || value.get<std::uint64_t>() <= INT64_MAX, "SQL integer overflow");
|
||||
sql_check(sqlite3_bind_int64(statement, index, value.get<std::int64_t>()), db);
|
||||
} else if (value.is_number_float()) sql_check(sqlite3_bind_double(statement, index, value.get<double>()), db);
|
||||
else throw std::runtime_error("SQL columns require scalar values");
|
||||
}
|
||||
Json value(int column) const {
|
||||
switch (sqlite3_column_type(statement, column)) {
|
||||
case SQLITE_TEXT: return text(column);
|
||||
case SQLITE_INTEGER: {
|
||||
const auto n = sqlite3_column_int64(statement, column);
|
||||
return n >= 0 ? Json(static_cast<std::uint64_t>(n)) : Json(n);
|
||||
}
|
||||
case SQLITE_FLOAT: return sqlite3_column_double(statement, column);
|
||||
case SQLITE_NULL: return nullptr;
|
||||
default: throw std::runtime_error("Unexpected SQL column type");
|
||||
}
|
||||
}
|
||||
bool row() {
|
||||
const auto code = sqlite3_step(statement);
|
||||
if (code == SQLITE_ROW) return true;
|
||||
if (code == SQLITE_DONE) return false;
|
||||
sql_check(code, db); return false;
|
||||
}
|
||||
std::string text(int column) const {
|
||||
check(sqlite3_column_type(statement, column) == SQLITE_TEXT, "Invalid save database record type");
|
||||
const auto* data = sqlite3_column_text(statement, column);
|
||||
const auto size = sqlite3_column_bytes(statement, column);
|
||||
check(data && size >= 0 && static_cast<std::size_t>(size) <= max_record, "Invalid save database text");
|
||||
return {reinterpret_cast<const char*>(data), static_cast<std::size_t>(size)};
|
||||
}
|
||||
void run() {
|
||||
check(!row(), "Unexpected save database result");
|
||||
sql_check(sqlite3_reset(statement), db); sql_check(sqlite3_clear_bindings(statement), db);
|
||||
}
|
||||
};
|
||||
class Transaction {
|
||||
sqlite3* db_;
|
||||
bool committed_{};
|
||||
public:
|
||||
explicit Transaction(sqlite3* db, bool write) : db_(db) { execute(db_, write ? "BEGIN IMMEDIATE" : "BEGIN"); }
|
||||
~Transaction() { if (!committed_) sqlite3_exec(db_, "ROLLBACK", nullptr, nullptr, nullptr); }
|
||||
void commit() { execute(db_, "COMMIT"); committed_ = true; }
|
||||
};
|
||||
int integer(sqlite3* db, const char* query) {
|
||||
Statement s(db, query); check(s.row(), "Missing save database metadata");
|
||||
const auto value = sqlite3_column_int(s.statement, 0);
|
||||
check(!s.row(), "Duplicate save database metadata"); return value;
|
||||
}
|
||||
void verify(sqlite3* db, std::string_view kind) {
|
||||
check(integer(db, "PRAGMA application_id") == application_id && integer(db, "PRAGMA user_version") == 1,
|
||||
"Unsupported ReSkate save database; original file retained");
|
||||
Statement type(db, "SELECT value FROM metadata WHERE key='kind'");
|
||||
check(type.row() && type.text(0) == kind && !type.row(), "Wrong ReSkate save database kind");
|
||||
Statement integrity(db, "PRAGMA quick_check(1)");
|
||||
check(integrity.row() && integrity.text(0) == "ok" && !integrity.row(), "Save database integrity check failed");
|
||||
}
|
||||
// Check complete trees without serializing the entire profile on each save.
|
||||
// Bounds also cover arrays stored as one record and future extension members.
|
||||
void bounds(const Json& value, std::size_t& nodes, std::size_t& bytes, unsigned depth = 0) {
|
||||
check(depth <= 32 && ++nodes <= max_nodes, "Save database tree exceeds complexity limit");
|
||||
bytes += 32;
|
||||
if (value.is_string()) bytes += value.string().size();
|
||||
else if (value.is_number_float()) check(std::isfinite(value.get<double>()), "Non-finite save value");
|
||||
else if (value.is_object()) for (const auto& [key, child] : value.items()) {
|
||||
bytes += key.size(); bounds(child, nodes, bytes, depth + 1);
|
||||
}
|
||||
else if (value.is_array()) for (const auto& child : value) bounds(child, nodes, bytes, depth + 1);
|
||||
check(bytes <= max_bytes, "Save database tree exceeds size limit");
|
||||
}
|
||||
void validate_tree(const Json& value) {
|
||||
check(value.is_object(), "Save database requires an object root");
|
||||
std::size_t nodes{}, bytes{}; bounds(value, nodes, bytes);
|
||||
}
|
||||
std::vector<std::string> columns(const SaveTable& table) {
|
||||
std::vector<std::string> result;
|
||||
std::string_view names = table.columns;
|
||||
while (!names.empty()) {
|
||||
const auto comma = names.find(','); result.emplace_back(names.substr(0, comma));
|
||||
if (comma == std::string_view::npos) break; names.remove_prefix(comma + 1);
|
||||
}
|
||||
check(!result.empty() && table.key_columns > 0 && table.key_columns <= result.size(), "Invalid save schema");
|
||||
return result;
|
||||
}
|
||||
void validate_tables(const Json& value, SaveSchema schema) {
|
||||
validate_tree(value);
|
||||
check(value.size() == schema.tables.size(), "Unexpected save tables");
|
||||
for (const auto& table : schema.tables) {
|
||||
check(value.contains(table.name) && value.at(table.name).is_object(), "Missing save table");
|
||||
const auto width = columns(table).size();
|
||||
for (const auto& [key, row] : value.at(table.name).items()) {
|
||||
check(row.is_array() && row.size() == width && row_key(row, table.key_columns) == key, "Invalid save row");
|
||||
for (const auto& cell : row) check(!cell.is_object() && !cell.is_array(), "Invalid SQL cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
Json load(sqlite3* db, SaveSchema schema) {
|
||||
Json root = Json::object(); std::size_t count{}, bytes{};
|
||||
for (const auto& table : schema.tables) {
|
||||
const auto names = columns(table);
|
||||
auto& rows = root[table.name]; rows = Json::object();
|
||||
const auto sql = "SELECT " + std::string(table.columns) + " FROM " + std::string(table.name);
|
||||
Statement records(db, sql.c_str());
|
||||
while (records.row()) {
|
||||
check(++count <= max_nodes, "Too many save rows");
|
||||
Json row = Json::array();
|
||||
for (std::size_t i = 0; i < names.size(); ++i) {
|
||||
auto value = records.value(static_cast<int>(i));
|
||||
bytes += value.is_string() ? value.string().size() : 16;
|
||||
check(bytes <= max_bytes, "Save database exceeds limit"); row.push_back(std::move(value));
|
||||
}
|
||||
const auto key = row_key(row, table.key_columns);
|
||||
check(!rows.contains(key), "Duplicate save database key"); rows[key] = std::move(row);
|
||||
}
|
||||
}
|
||||
validate_tables(root, schema); return root;
|
||||
}
|
||||
void apply_changes(sqlite3* db, SaveSchema schema, const Json* before, const Json& after) {
|
||||
for (const auto& table : schema.tables) {
|
||||
const auto& next = after.at(table.name);
|
||||
const Json* old = before ? &before->at(table.name) : nullptr;
|
||||
if (old && *old == next) continue;
|
||||
const auto names = columns(table);
|
||||
std::string insert = "INSERT INTO " + std::string(table.name) + '(' + std::string(table.columns) + ") VALUES(";
|
||||
std::string remove = "DELETE FROM " + std::string(table.name) + " WHERE ";
|
||||
for (std::size_t i = 0; i < names.size(); ++i) { if (i) insert += ','; insert += '?'; }
|
||||
insert += ") ON CONFLICT(";
|
||||
for (unsigned i = 0; i < table.key_columns; ++i) {
|
||||
if (i) { insert += ','; remove += " AND "; }
|
||||
insert += names[i]; remove += names[i] + "=?";
|
||||
}
|
||||
insert += ')';
|
||||
if (table.key_columns == names.size()) insert += " DO NOTHING";
|
||||
else {
|
||||
insert += " DO UPDATE SET ";
|
||||
for (std::size_t i = table.key_columns; i < names.size(); ++i) {
|
||||
if (i != table.key_columns) insert += ',';
|
||||
insert += names[i] + "=excluded." + names[i];
|
||||
}
|
||||
}
|
||||
Statement erase(db, remove.c_str()), upsert(db, insert.c_str());
|
||||
if (old) for (const auto& [key, row] : old->items()) if (!next.contains(key)) {
|
||||
for (unsigned i = 0; i < table.key_columns; ++i) erase.bind(static_cast<int>(i + 1), row[i]);
|
||||
erase.run();
|
||||
}
|
||||
for (const auto& [key, row] : next.items()) {
|
||||
if (old && old->contains(key) && old->at(key) == row) continue;
|
||||
for (std::size_t i = 0; i < names.size(); ++i) upsert.bind(static_cast<int>(i + 1), row[i]);
|
||||
upsert.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
void configure_writer(sqlite3* db) {
|
||||
Statement mode(db, "PRAGMA journal_mode=WAL");
|
||||
check(mode.row() && mode.text(0) == "wal" && !mode.row(), "Cannot enable save database WAL");
|
||||
execute(db, "PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA wal_autocheckpoint=256; PRAGMA journal_size_limit=1048576; PRAGMA max_page_count=16384;");
|
||||
}
|
||||
void check_size(const std::filesystem::path& path) { check(std::filesystem::file_size(path) <= max_bytes, "Save database exceeds size limit"); }
|
||||
void clear_staging(const std::filesystem::path& path) {
|
||||
// These are unpublished files owned by the locked writer. Remove journals
|
||||
// too, so a retry cannot recover an abandoned transaction into a new file.
|
||||
for (const auto* suffix : {L"", L"-journal", L"-wal", L"-shm"}) {
|
||||
const auto file = path.wstring() + suffix;
|
||||
if (std::filesystem::exists(file)) check(!!DeleteFileW(file.c_str()), "Cannot clear interrupted save staging");
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string row_key(const Json& row, unsigned key_columns) {
|
||||
check(row.is_array() && row.size() >= key_columns, "Invalid save key");
|
||||
Json key = Json::array(); for (unsigned i = 0; i < key_columns; ++i) key.push_back(row[i]);
|
||||
return key.dump();
|
||||
}
|
||||
std::filesystem::path database_path(std::filesystem::path path) {
|
||||
if (path.extension() == L".json") path.replace_extension(L".sqlite3"); return path;
|
||||
}
|
||||
std::filesystem::path json_source_path(std::filesystem::path path) { return path.replace_extension(L".json"); }
|
||||
struct SaveDatabase::Impl {
|
||||
std::filesystem::path path;
|
||||
SaveSchema schema;
|
||||
std::size_t nodes{}, bytes{};
|
||||
std::vector<HANDLE> locks;
|
||||
std::unique_ptr<Connection> connection;
|
||||
Json value;
|
||||
int version{};
|
||||
~Impl() { connection.reset(); for (const auto handle : locks) CloseHandle(handle); }
|
||||
void lock(const std::filesystem::path& target) {
|
||||
const auto handle = CreateFileW((target.wstring() + L".lock").c_str(), GENERIC_READ | GENERIC_WRITE,
|
||||
0, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
check(handle != INVALID_HANDLE_VALUE, "Save is already in use or inaccessible; close the game before editing");
|
||||
try { locks.push_back(handle); } catch (...) { CloseHandle(handle); throw; }
|
||||
}
|
||||
};
|
||||
SaveDatabase::SaveDatabase(std::filesystem::path path, SaveSchema schema) : impl_(std::make_unique<Impl>()) {
|
||||
auto& s = *impl_; s.path = database_path(std::move(path)); s.schema = schema;
|
||||
if (!s.path.parent_path().empty()) std::filesystem::create_directories(s.path.parent_path());
|
||||
s.lock(s.path); s.lock(json_source_path(s.path)); // Excludes older JSON writers too.
|
||||
const auto legacy = s.path.parent_path() / L"profile.rsp";
|
||||
if (schema.name == "profile" && std::filesystem::exists(legacy)) s.lock(legacy);
|
||||
if (!std::filesystem::exists(s.path)) {
|
||||
check(!std::filesystem::exists(s.path.wstring() + L".bak") && !std::filesystem::exists(s.path.wstring() + L"-wal") &&
|
||||
!std::filesystem::exists(s.path.wstring() + L"-shm") && !std::filesystem::exists(s.path.wstring() + L"-journal"), "Save database missing while recovery files exist"); return;
|
||||
}
|
||||
check_size(s.path);
|
||||
s.connection = std::make_unique<Connection>(s.path, SQLITE_OPEN_READWRITE);
|
||||
Transaction read(s.connection->db, false); verify(s.connection->db, schema.name); s.value = load(s.connection->db, schema);
|
||||
s.version = integer(s.connection->db, "PRAGMA data_version"); read.commit();
|
||||
bounds(s.value, s.nodes, s.bytes);
|
||||
}
|
||||
SaveDatabase::~SaveDatabase() = default;
|
||||
bool SaveDatabase::exists() const { return !!impl_->connection; }
|
||||
const Json& SaveDatabase::document() const { check(exists(), "Save database is not initialized"); return impl_->value; }
|
||||
void SaveDatabase::initialize(Json value) {
|
||||
check(!exists(), "Save database is already initialized"); validate_tables(value, impl_->schema);
|
||||
auto& s = *impl_;
|
||||
const auto temporary = s.path.wstring() + L".migrating";
|
||||
// Unpublished staging belongs to this writer. Retrying a crashed migration
|
||||
// never replaces the active database or the retained original JSON.
|
||||
clear_staging(temporary);
|
||||
{
|
||||
Connection initial(temporary, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
|
||||
execute(initial.db, "PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA max_page_count=16384;");
|
||||
Transaction transaction(initial.db, true);
|
||||
execute(initial.db, "PRAGMA application_id=1381190484; PRAGMA user_version=1;"
|
||||
"CREATE TABLE metadata(key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL) STRICT, WITHOUT ROWID;");
|
||||
for (const auto& table : s.schema.tables) execute(initial.db, std::string(table.create_sql).c_str());
|
||||
Statement type(initial.db, "INSERT INTO metadata VALUES('kind',?1)"); type.bind(1, s.schema.name); type.run();
|
||||
apply_changes(initial.db, s.schema, nullptr, value); transaction.commit();
|
||||
}
|
||||
check(!!MoveFileExW(temporary.c_str(), s.path.c_str(), MOVEFILE_WRITE_THROUGH), "Cannot publish migrated save database");
|
||||
s.connection = std::make_unique<Connection>(s.path, SQLITE_OPEN_READWRITE);
|
||||
verify(s.connection->db, s.schema.name); configure_writer(s.connection->db);
|
||||
s.version = integer(s.connection->db, "PRAGMA data_version"); s.value = std::move(value);
|
||||
s.nodes = s.bytes = 0; bounds(s.value, s.nodes, s.bytes);
|
||||
}
|
||||
void SaveDatabase::commit(Json value) {
|
||||
auto& s = *impl_; check(exists(), "Save database is not initialized"); validate_tables(value, s.schema);
|
||||
if (s.value == value) return;
|
||||
Transaction transaction(s.connection->db, true);
|
||||
check(integer(s.connection->db, "PRAGMA data_version") == s.version, "Save database edited externally; restart to load changes");
|
||||
apply_changes(s.connection->db, s.schema, &s.value, value); transaction.commit();
|
||||
s.value = std::move(value); // Memory is published only after durable COMMIT.
|
||||
s.nodes = s.bytes = 0; bounds(s.value, s.nodes, s.bytes);
|
||||
}
|
||||
void SaveDatabase::patch(Json patch) {
|
||||
auto& s = *impl_;
|
||||
check(exists() && patch.is_object(), "Invalid save patch");
|
||||
Json before = Json::object(), after = Json::object();
|
||||
for (const auto& table : s.schema.tables) {
|
||||
before[table.name] = Json::object(); after[table.name] = Json::object();
|
||||
}
|
||||
auto nodes = s.nodes, bytes = s.bytes;
|
||||
struct Publication {
|
||||
Json::Object* rows;
|
||||
Json::Object::iterator old;
|
||||
Json::Object::node_type replacement;
|
||||
};
|
||||
std::vector<Publication> publications;
|
||||
for (auto& [table_name, changes] : patch.items()) {
|
||||
const auto table = std::find_if(s.schema.tables.begin(), s.schema.tables.end(),
|
||||
[&](const auto& item) { return item.name == table_name; });
|
||||
check(table != s.schema.tables.end() && changes.is_object(), "Unknown save patch table");
|
||||
const auto width = columns(*table).size();
|
||||
auto& rows = s.value.at(table_name).items();
|
||||
for (auto it = changes.items().begin(); it != changes.items().end();) {
|
||||
auto current = it++;
|
||||
const auto& key = current->first;
|
||||
const auto& row = current->second;
|
||||
auto old = rows.find(key);
|
||||
if ((old == rows.end() && row.is_null()) ||
|
||||
(old != rows.end() && old->second == row)) continue;
|
||||
if (old != rows.end()) {
|
||||
std::size_t old_nodes{}, old_bytes{};
|
||||
bounds(old->second, old_nodes, old_bytes, 2);
|
||||
nodes -= old_nodes; bytes -= old_bytes + key.size();
|
||||
before[table_name][key] = old->second;
|
||||
}
|
||||
if (!row.is_null()) {
|
||||
check(row.is_array() && row.size() == width &&
|
||||
row_key(row, table->key_columns) == key, "Invalid save patch row");
|
||||
for (const auto& cell : row) check(!cell.is_object() && !cell.is_array(), "Invalid SQL cell");
|
||||
std::size_t new_nodes{}, new_bytes{};
|
||||
bounds(row, new_nodes, new_bytes, 2);
|
||||
nodes += new_nodes; bytes += new_bytes + key.size();
|
||||
after[table_name][key] = row;
|
||||
}
|
||||
publications.push_back({&rows, old, {}});
|
||||
if (!row.is_null()) publications.back().replacement = changes.items().extract(current);
|
||||
}
|
||||
}
|
||||
if (publications.empty()) return;
|
||||
check(nodes <= max_nodes && bytes <= max_bytes, "Save patch exceeds database limits");
|
||||
Transaction transaction(s.connection->db, true);
|
||||
check(integer(s.connection->db, "PRAGMA data_version") == s.version,
|
||||
"Save database edited externally; restart to load changes");
|
||||
const auto changed_before = sqlite3_total_changes64(s.connection->db);
|
||||
apply_changes(s.connection->db, s.schema, &before, after);
|
||||
check(sqlite3_total_changes64(s.connection->db) - changed_before == static_cast<sqlite3_int64>(publications.size()),
|
||||
"Save patch changed rows outside its declared records");
|
||||
transaction.commit();
|
||||
// Every replacement map node was allocated before COMMIT. Publishing the
|
||||
// cache uses only erasure/node transfer, so allocation failures cannot leave
|
||||
// a successfully saved transaction absent from this session's read model.
|
||||
for (auto& item : publications) {
|
||||
if (item.old != item.rows->end()) item.rows->erase(item.old);
|
||||
if (!item.replacement.empty()) item.rows->insert(std::move(item.replacement));
|
||||
}
|
||||
s.nodes = nodes; s.bytes = bytes;
|
||||
}
|
||||
void SaveDatabase::backup() {
|
||||
auto& s = *impl_; check(exists(), "Save database is not initialized");
|
||||
const auto temporary = s.path.wstring() + L".bak.pending";
|
||||
clear_staging(temporary);
|
||||
{
|
||||
Transaction read(s.connection->db, false);
|
||||
check(integer(s.connection->db, "PRAGMA data_version") == s.version, "Save database changed during load");
|
||||
Connection destination(temporary, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
|
||||
auto* backup = sqlite3_backup_init(destination.db, "main", s.connection->db, "main");
|
||||
check(backup != nullptr, "Cannot initialize save backup");
|
||||
const auto result = sqlite3_backup_step(backup, -1); const auto finish = sqlite3_backup_finish(backup);
|
||||
check(result == SQLITE_DONE && finish == SQLITE_OK, "Cannot complete save backup");
|
||||
execute(destination.db, "PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA;"); read.commit();
|
||||
}
|
||||
check(!!MoveFileExW(temporary.c_str(), (s.path.wstring() + L".bak").c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH),
|
||||
"Cannot publish save backup");
|
||||
configure_writer(s.connection->db);
|
||||
check(integer(s.connection->db, "PRAGMA data_version") == s.version, "Save database changed during backup");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk::storage {
|
||||
std::filesystem::path database_path(std::filesystem::path);
|
||||
std::filesystem::path json_source_path(std::filesystem::path);
|
||||
|
||||
// Each row is an array matching the named SQL columns. The first key_columns
|
||||
// fields form its primary key; table names and SQL come from static schemas.
|
||||
struct SaveTable {
|
||||
std::string_view name, columns;
|
||||
unsigned key_columns;
|
||||
std::string_view create_sql;
|
||||
};
|
||||
struct SaveSchema {
|
||||
std::string_view name;
|
||||
std::span<const SaveTable> tables;
|
||||
};
|
||||
// A document maps table names to rows keyed by row_key(). Only changed rows
|
||||
// enter each transaction; reads use the cached document.
|
||||
std::string row_key(const Json& row, unsigned key_columns);
|
||||
class SaveDatabase {
|
||||
public:
|
||||
SaveDatabase(std::filesystem::path, SaveSchema schema);
|
||||
~SaveDatabase();
|
||||
SaveDatabase(const SaveDatabase&) = delete;
|
||||
SaveDatabase& operator=(const SaveDatabase&) = delete;
|
||||
bool exists() const;
|
||||
const Json& document() const;
|
||||
void initialize(Json);
|
||||
void commit(Json);
|
||||
// Sparse table rows; null deletes a row. Atomic cache/disk publication.
|
||||
void patch(Json);
|
||||
// After domain validation, before the first write of an existing session.
|
||||
void backup();
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "Engine/Core/Text/word_filter.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void expect(bool condition, std::string_view what) {
|
||||
if (condition) return;
|
||||
++failures;
|
||||
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
|
||||
}
|
||||
|
||||
void bad(std::string_view text) { expect(dingosdk::text::contains_bad_words(text), std::string("bad: ") + std::string(text)); }
|
||||
void clean(std::string_view text) { expect(!dingosdk::text::contains_bad_words(text), std::string("clean: ") + std::string(text)); }
|
||||
void masks(std::string_view text, std::string_view expected) {
|
||||
const auto masked = dingosdk::text::mask_bad_words(text);
|
||||
expect(masked == expected, std::string("mask: \"") + std::string(text) + "\" -> \"" + masked + "\", wanted \"" +
|
||||
std::string(expected) + "\"");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// Listed words, any case, whole or inside longer words.
|
||||
bad("fuck");
|
||||
bad("FUCK this server");
|
||||
bad("Fuckface Skaters");
|
||||
bad("motherfuckers only");
|
||||
bad("Shithead's park");
|
||||
bad("big ass ramps");
|
||||
bad("Bitch Please");
|
||||
// Look-alikes and spelled-out letters.
|
||||
bad("sh1t");
|
||||
bad("@$$ hats");
|
||||
bad("f u c k");
|
||||
bad("F.U.C.K. yeah");
|
||||
bad("b17ch");
|
||||
// Ordinary words that contain a listed word, and server names people use.
|
||||
clean("Hello World");
|
||||
clean("Shell Shock Skatepark");
|
||||
clean("Classic Skate Session");
|
||||
clean("Pass the Grass");
|
||||
clean("Scunthorpe Skaters");
|
||||
clean("Cocktail Hour");
|
||||
clean("Peacock Plaza");
|
||||
clean("Dickies Team Session");
|
||||
clean("Arsenal FC fans");
|
||||
clean("Sparse Parsec");
|
||||
clean("Saturday Night Session");
|
||||
clean("Reputation Skate");
|
||||
clean("San Vansterdam 24/7");
|
||||
clean("Titanic Ledges");
|
||||
clean("Cumulus Bowl");
|
||||
clean("Essex Street League");
|
||||
clean("Pakistan Plaza");
|
||||
clean("Scrapyard DIY");
|
||||
clean("ReSkate server");
|
||||
clean("");
|
||||
// Masking keeps everything else and the length.
|
||||
masks("what the fuck dude", "what the **** dude");
|
||||
masks("shit happens", "**** happens");
|
||||
masks("sh1t", "****");
|
||||
masks("f u c k", "* * * *");
|
||||
masks("Hello friends", "Hello friends");
|
||||
masks("fuckface", "****face");
|
||||
if (failures == 0) std::printf("word filter: all checks passed\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
@$$
|
||||
AssMonkey
|
||||
Assface
|
||||
Biatch
|
||||
BlowJob
|
||||
CarpetMuncher
|
||||
Clit
|
||||
Cock
|
||||
CockSucker
|
||||
Ekrem
|
||||
Ekto
|
||||
Felcher
|
||||
Flikker
|
||||
Fotze
|
||||
Fu
|
||||
FudgePacker
|
||||
Fukah
|
||||
Fuken
|
||||
Fukin
|
||||
Fukk
|
||||
Fukkah
|
||||
Fukken
|
||||
Fukker
|
||||
Fukkin
|
||||
Goddamned
|
||||
Huevon
|
||||
Kurac
|
||||
Lesbian
|
||||
Lezzian
|
||||
Lipshits
|
||||
Lipshitz
|
||||
MothaFucker
|
||||
MothaFuker
|
||||
MothaFukkah
|
||||
MothaFukker
|
||||
MotherFucker
|
||||
MotherFukah
|
||||
MotherFuker
|
||||
MotherFukkah
|
||||
MotherFukker
|
||||
MuthaFucker
|
||||
MuthaFukah
|
||||
MuthaFuker
|
||||
MuthaFukkah
|
||||
MuthaFukker
|
||||
Phuc
|
||||
Phuck
|
||||
Phuk
|
||||
Phuker
|
||||
Phukker
|
||||
Poonani
|
||||
Shitty
|
||||
Shity
|
||||
Sht
|
||||
Shyt
|
||||
Shyte
|
||||
Shytty
|
||||
Skanky
|
||||
Slutty
|
||||
ahole
|
||||
amcik
|
||||
andskota
|
||||
anus
|
||||
arschloch
|
||||
arse
|
||||
ash0le
|
||||
ash0les
|
||||
asholes
|
||||
ass
|
||||
assh0le
|
||||
assh0lez
|
||||
asshole
|
||||
assholes
|
||||
assholz
|
||||
assrammer
|
||||
asswipe
|
||||
ayir
|
||||
azzhole
|
||||
b00bs
|
||||
b17ch
|
||||
b1tch
|
||||
bassterds
|
||||
bastard
|
||||
bastards
|
||||
bastardz
|
||||
basterds
|
||||
basterdz
|
||||
bch
|
||||
bi7ch
|
||||
bich
|
||||
bitch
|
||||
bitches
|
||||
blowjob
|
||||
boffing
|
||||
boiolas
|
||||
bollock
|
||||
boobs
|
||||
breasts
|
||||
btch
|
||||
buceta
|
||||
bullshit
|
||||
butthole
|
||||
buttpirate
|
||||
buttwipe
|
||||
c0ck
|
||||
c0cks
|
||||
c0k
|
||||
cabron
|
||||
cawk
|
||||
cawks
|
||||
cazzo
|
||||
chink
|
||||
chraa
|
||||
chuj
|
||||
cipa
|
||||
clit
|
||||
clits
|
||||
cnts
|
||||
cntz
|
||||
cock
|
||||
cockhead
|
||||
cocks
|
||||
cocksucker
|
||||
crap
|
||||
cum
|
||||
cunt
|
||||
cunts
|
||||
cuntz
|
||||
d4mn
|
||||
damn
|
||||
daygo
|
||||
dego
|
||||
dick
|
||||
dike
|
||||
dild0
|
||||
dild0s
|
||||
dildo
|
||||
dildos
|
||||
dilld0
|
||||
dilld0s
|
||||
dirsa
|
||||
dominatricks
|
||||
dominatrics
|
||||
dominatrix
|
||||
dupa
|
||||
dyke
|
||||
dziwka
|
||||
ejackulate
|
||||
ejakulate
|
||||
enculer
|
||||
enema
|
||||
faen
|
||||
fag
|
||||
fag1t
|
||||
faget
|
||||
fagg1t
|
||||
faggit
|
||||
faggot
|
||||
fagit
|
||||
fags
|
||||
fagz
|
||||
faig
|
||||
faigs
|
||||
fanculo
|
||||
fanny
|
||||
fart
|
||||
fatass
|
||||
fcuk
|
||||
feces
|
||||
feg
|
||||
ficken
|
||||
fitt
|
||||
flipping
|
||||
foreskin
|
||||
fuchah
|
||||
fuck
|
||||
fucka
|
||||
fucker
|
||||
fuckin
|
||||
fucking
|
||||
fucks
|
||||
fuk
|
||||
fukah
|
||||
fuker
|
||||
fukka
|
||||
fukkah
|
||||
fukker
|
||||
futkretzn
|
||||
fux0r
|
||||
g00k
|
||||
gay
|
||||
gaybor
|
||||
gayboy
|
||||
gaygirl
|
||||
gays
|
||||
gayz
|
||||
gook
|
||||
guiena
|
||||
h00r
|
||||
h0ar
|
||||
h0r
|
||||
h0re
|
||||
h4x0r
|
||||
hell
|
||||
hells
|
||||
helvete
|
||||
hoar
|
||||
hoer
|
||||
honkey
|
||||
hoor
|
||||
hoore
|
||||
hore
|
||||
hui
|
||||
injun
|
||||
jackoff
|
||||
jap
|
||||
japs
|
||||
jerkoff
|
||||
jisim
|
||||
jism
|
||||
jiss
|
||||
jizm
|
||||
jizz
|
||||
kanker
|
||||
kawk
|
||||
kike
|
||||
klootzak
|
||||
knob
|
||||
knobs
|
||||
knobz
|
||||
knulle
|
||||
kraut
|
||||
kuk
|
||||
kuksuger
|
||||
kunt
|
||||
kunts
|
||||
kuntz
|
||||
kurwa
|
||||
kusi
|
||||
kyrpa
|
||||
l3i+ch
|
||||
l3itch
|
||||
lesbian
|
||||
lesbo
|
||||
mamhoon
|
||||
masochist
|
||||
masokist
|
||||
massterbait
|
||||
masstrbait
|
||||
masstrbate
|
||||
masterbaiter
|
||||
masterbat
|
||||
masterbat3
|
||||
masterbate
|
||||
masterbates
|
||||
masturbat
|
||||
masturbate
|
||||
merd
|
||||
mibun
|
||||
mofo
|
||||
monkleigh
|
||||
motha
|
||||
motherfucker
|
||||
mouliewop
|
||||
muie
|
||||
mulkku
|
||||
muschi
|
||||
mutha
|
||||
n1gr
|
||||
nastt
|
||||
nasty
|
||||
nazi
|
||||
nazis
|
||||
nepesaurio
|
||||
nigga
|
||||
niggas
|
||||
nigger
|
||||
nigur
|
||||
niiger
|
||||
niigr
|
||||
nutsack
|
||||
orafis
|
||||
orgasim
|
||||
orgasm
|
||||
orgasum
|
||||
oriface
|
||||
orifice
|
||||
orifiss
|
||||
orospu
|
||||
p0rn
|
||||
packi
|
||||
packie
|
||||
packy
|
||||
paki
|
||||
pakie
|
||||
paky
|
||||
paska
|
||||
pecker
|
||||
peeenus
|
||||
peeenusss
|
||||
peenus
|
||||
peinus
|
||||
pen1s
|
||||
penas
|
||||
penis
|
||||
penisbreath
|
||||
penus
|
||||
penuus
|
||||
perse
|
||||
phuck
|
||||
picka
|
||||
pierdol
|
||||
pillu
|
||||
pimmel
|
||||
pimpis
|
||||
piss
|
||||
pizda
|
||||
polac
|
||||
polack
|
||||
polak
|
||||
poontsee
|
||||
poop
|
||||
porn
|
||||
pr0n
|
||||
pr1c
|
||||
pr1ck
|
||||
pr1k
|
||||
preteen
|
||||
pula
|
||||
pule
|
||||
pusse
|
||||
pussee
|
||||
pussy
|
||||
puta
|
||||
puto
|
||||
puuke
|
||||
puuker
|
||||
qahbeh
|
||||
queef
|
||||
queer
|
||||
queers
|
||||
queerz
|
||||
qweers
|
||||
qweerz
|
||||
qweir
|
||||
rautenberg
|
||||
recktum
|
||||
rectum
|
||||
retard
|
||||
s.o.b.
|
||||
sadist
|
||||
scank
|
||||
schaffer
|
||||
scheiss
|
||||
schlampe
|
||||
schlong
|
||||
schmuck
|
||||
screw
|
||||
screwing
|
||||
scrotum
|
||||
semen
|
||||
sex
|
||||
sexx
|
||||
sexxx
|
||||
sexy
|
||||
sh1t
|
||||
sh1ter
|
||||
sh1ts
|
||||
sh1tter
|
||||
sh1tz
|
||||
sharmuta
|
||||
sharmute
|
||||
shemale
|
||||
shi+
|
||||
shipal
|
||||
shit
|
||||
shits
|
||||
shitt
|
||||
shitter
|
||||
shitz
|
||||
shiz
|
||||
skanck
|
||||
skank
|
||||
skankee
|
||||
skankey
|
||||
skanks
|
||||
skrib
|
||||
slut
|
||||
sluts
|
||||
slutz
|
||||
smut
|
||||
sonofabitch
|
||||
sx
|
||||
teets
|
||||
teez
|
||||
testical
|
||||
testicle
|
||||
tit
|
||||
tits
|
||||
titt
|
||||
turd
|
||||
va1jina
|
||||
vag1na
|
||||
vagiina
|
||||
vagina
|
||||
vaj1na
|
||||
vajina
|
||||
vullva
|
||||
vulva
|
||||
w00se
|
||||
w0p
|
||||
wank
|
||||
wh00r
|
||||
wh0re
|
||||
whoar
|
||||
whore
|
||||
xrated
|
||||
xxx
|
||||
@@ -0,0 +1,172 @@
|
||||
#include "word_filter.h"
|
||||
|
||||
#include "embedded_bad_words.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::text {
|
||||
namespace {
|
||||
// Letters fold to lower case and look-alikes to the letter they stand for; anything else
|
||||
// separates words.
|
||||
char fold(char c) {
|
||||
if (c >= 'a' && c <= 'z') return c;
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c - 'A' + 'a');
|
||||
switch (c) {
|
||||
case '0': return 'o';
|
||||
case '1': return 'i';
|
||||
case '3': return 'e';
|
||||
case '4': return 'a';
|
||||
case '5': return 's';
|
||||
case '7': return 't';
|
||||
case '8': return 'b';
|
||||
case '@': return 'a';
|
||||
case '$': return 's';
|
||||
case '+': return 't';
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Longer list words that are common inside ordinary ones ("hell" in hello, "arse" in parse,
|
||||
// "puta" in reputation, "turd" in saturday): these only count as a whole word.
|
||||
constexpr std::string_view whole_word_only[]{
|
||||
"anus", "arse", "ayir", "bich", "breasts", "cawk", "cawks", "chuj", "cipa", "crap",
|
||||
"dego", "dike", "dupa", "ekto", "faen", "faig", "faigs", "fanny", "fart", "fitt",
|
||||
"flipping", "gays", "gayz", "gook", "hell", "hells", "hoar", "hoer", "hoor", "hore",
|
||||
"injun", "jiss", "kawk", "knob", "knobs", "knobz", "kraut", "kunt", "kunts", "kuntz",
|
||||
"kusi", "merd", "muie", "nasty", "nastt", "packi", "packie", "packy", "paki", "pakie",
|
||||
"paky", "paska", "perse", "picka", "pillu", "polac", "polak", "poop", "pric", "prik",
|
||||
"pron", "pula", "pule", "pusse", "puta", "puto", "rautenberg", "schaffer", "screw", "screwing",
|
||||
"semen", "shiz", "smut", "teets", "teez", "tits", "titt", "turd", "woose"};
|
||||
|
||||
// Ordinary words with a bad word inside; a match that one of these covers does not count.
|
||||
constexpr std::string_view allowed_words[]{
|
||||
"scunthorpe", "dickens", "dickinson", "dickson", "dickies", "cocktail", "cockpit", "peacock",
|
||||
"cockroach", "cockatoo", "cockatiel", "cockney", "cockle", "cockerel", "cockburn", "cocky",
|
||||
"hancock", "hitchcock", "woodcock", "babcock", "shuttlecock", "ashkenazi", "shiitake", "shitake",
|
||||
"matsushita", "yamashita", "kinoshita", "morishita", "takeshita", "swank", "swanky", "woodpecker",
|
||||
"clitheroe", "snigger", "pussycat", "pissarro", "retardant"};
|
||||
|
||||
struct Hash {
|
||||
using is_transparent = void;
|
||||
std::size_t operator()(std::string_view text) const { return std::hash<std::string_view>{}(text); }
|
||||
};
|
||||
using WordSet = std::unordered_set<std::string, Hash, std::equal_to<>>;
|
||||
|
||||
struct Lists {
|
||||
WordSet whole; // every listed word
|
||||
WordSet inside; // the ones that also match inside longer words
|
||||
std::size_t shortest_inside{~std::size_t{}}, longest_inside{};
|
||||
};
|
||||
|
||||
const Lists& lists() {
|
||||
static const Lists built = [] {
|
||||
Lists l;
|
||||
std::string_view text = embedded::bad_words_list;
|
||||
while (!text.empty()) {
|
||||
const auto end = text.find('\n');
|
||||
const auto line = text.substr(0, end);
|
||||
text = end == std::string_view::npos ? std::string_view{} : text.substr(end + 1);
|
||||
std::string word;
|
||||
for (const char c : line)
|
||||
if (const char letter = fold(c)) word += letter;
|
||||
if (word.empty()) continue;
|
||||
l.whole.insert(word);
|
||||
if (word.size() > 3 && std::ranges::find(whole_word_only, word) == std::ranges::end(whole_word_only)) {
|
||||
l.shortest_inside = std::min(l.shortest_inside, word.size());
|
||||
l.longest_inside = std::max(l.longest_inside, word.size());
|
||||
l.inside.insert(std::move(word));
|
||||
}
|
||||
}
|
||||
return l;
|
||||
}();
|
||||
return built;
|
||||
}
|
||||
|
||||
// A word as folded letters, with where each letter sits in the text.
|
||||
struct Word {
|
||||
std::string letters;
|
||||
std::vector<std::size_t> at;
|
||||
};
|
||||
|
||||
std::vector<Word> words(std::string_view text) {
|
||||
std::vector<Word> out;
|
||||
Word current;
|
||||
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||
if (const char letter = fold(text[i])) {
|
||||
current.letters += letter;
|
||||
current.at.push_back(i);
|
||||
} else if (!current.letters.empty()) {
|
||||
out.push_back(std::move(current));
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
if (!current.letters.empty()) out.push_back(std::move(current));
|
||||
// Three or more letters spelled out one at a time ("f u c k", "s.o.b.") also read as a word.
|
||||
std::vector<Word> spelled;
|
||||
for (std::size_t i = 0; i < out.size();) {
|
||||
auto end = i;
|
||||
while (end < out.size() && out[end].letters.size() == 1) ++end;
|
||||
if (end - i >= 3) {
|
||||
Word joined;
|
||||
for (auto k = i; k < end; ++k) {
|
||||
joined.letters += out[k].letters;
|
||||
joined.at.push_back(out[k].at.front());
|
||||
}
|
||||
spelled.push_back(std::move(joined));
|
||||
}
|
||||
i = end == i ? i + 1 : end;
|
||||
}
|
||||
out.insert(out.end(), std::make_move_iterator(spelled.begin()), std::make_move_iterator(spelled.end()));
|
||||
return out;
|
||||
}
|
||||
|
||||
bool covered(std::string_view letters, std::size_t start, std::size_t length) {
|
||||
for (const auto allowed : allowed_words)
|
||||
for (auto at = letters.find(allowed); at != std::string_view::npos; at = letters.find(allowed, at + 1))
|
||||
if (at <= start && start + length <= at + allowed.size()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether `word` has a bad word in it; with `marked`, flags each letter that belongs to one.
|
||||
bool match(const Word& word, std::vector<bool>* marked) {
|
||||
const auto& l = lists();
|
||||
const std::string_view letters = word.letters;
|
||||
if (l.whole.contains(letters)) {
|
||||
if (marked) marked->assign(letters.size(), true);
|
||||
return true;
|
||||
}
|
||||
bool found{};
|
||||
for (std::size_t start = 0; start + l.shortest_inside <= letters.size(); ++start)
|
||||
for (auto length = l.shortest_inside; length <= l.longest_inside && start + length <= letters.size(); ++length) {
|
||||
if (!l.inside.contains(letters.substr(start, length)) || covered(letters, start, length)) continue;
|
||||
if (!marked) return true;
|
||||
found = true;
|
||||
std::fill(marked->begin() + static_cast<std::ptrdiff_t>(start),
|
||||
marked->begin() + static_cast<std::ptrdiff_t>(start + length), true);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool contains_bad_words(std::string_view text) {
|
||||
for (const auto& word : words(text))
|
||||
if (match(word, nullptr)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string mask_bad_words(std::string_view text) {
|
||||
std::string out(text);
|
||||
for (const auto& word : words(text)) {
|
||||
std::vector<bool> marked(word.letters.size());
|
||||
if (!match(word, &marked)) continue;
|
||||
for (std::size_t i = 0; i < marked.size(); ++i)
|
||||
if (marked[i]) out[word.at[i]] = '*';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace dingosdk::text
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// The bad-word filter: the list in bad_words.txt (built in), matched against text after
|
||||
// folding case and look-alike characters, so "Sh1t", "@$$" and "f u c k" count too.
|
||||
//
|
||||
// - Words of three letters or fewer, and a few longer ones that are common inside ordinary
|
||||
// words ("hell" in hello, "arse" in parse), only match a whole word.
|
||||
// - Every other word also matches inside a longer one ("fuckface"), unless an ordinary word
|
||||
// covers it there ("Scunthorpe", "cocktail").
|
||||
//
|
||||
// Used for dedicated server names (the server refuses to list one, the client hides it) and,
|
||||
// as a player option, to mask chat.
|
||||
namespace dingosdk::text {
|
||||
bool contains_bad_words(std::string_view text);
|
||||
|
||||
// `text` with each letter of every bad word replaced by '*'; same length, other text untouched.
|
||||
std::string mask_bad_words(std::string_view text);
|
||||
} // namespace dingosdk::text
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "Engine/Game/Abi/native_callback_queue.h"
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
struct Request { std::uintptr_t callback; };
|
||||
std::array<unsigned, 16> destroyed{};
|
||||
void destroy(void* wrapper) {
|
||||
const auto value = *static_cast<std::uintptr_t*>(wrapper);
|
||||
if (value < destroyed.size()) ++destroyed[value];
|
||||
}
|
||||
void check(bool result, const char* message) { if (!result) throw std::runtime_error(message); }
|
||||
void regression() {
|
||||
using dingosdk::game::NativeCallbackQueue;
|
||||
dingosdk::game::native_data().values.destroy_delegate = destroy;
|
||||
NativeCallbackQueue<Request> queue;
|
||||
unsigned invoked{};
|
||||
bool assets_alive = true;
|
||||
const auto invoke = [&](const Request&, std::uintptr_t) {
|
||||
check(assets_alive, "A callback reached an unloaded script descriptor");
|
||||
++invoked;
|
||||
};
|
||||
// A Build Kit request can wait for inventory across many client ticks.
|
||||
queue.push({1}); queue.push({2});
|
||||
queue.before_transition(14);
|
||||
assets_alive = false;
|
||||
queue.deliver(invoke);
|
||||
queue.before_transition(3);
|
||||
check(invoked == 0 && destroyed[1] == 1 && destroyed[2] == 1 && !queue.available(),
|
||||
"Native unload must cancel retained callbacks once, even with inventory still unavailable");
|
||||
// The splash screen subscribes during construction, before state 13.
|
||||
queue.before_transition(4); assets_alive = true;
|
||||
check(queue.available(), "Fresh loading-world subscriptions must be accepted");
|
||||
queue.push({3}); queue.deliver(invoke);
|
||||
check(invoked == 1 && destroyed[3] == 1, "Fresh subscriptions must still complete normally");
|
||||
// A callback may rebuild/unload the world, invalidating the rest of its batch.
|
||||
queue.push({4}); queue.push({5});
|
||||
queue.deliver([&](const Request&, std::uintptr_t callback) {
|
||||
check(callback == 4, "Reentrant unload left a stale callback in the active batch");
|
||||
++invoked;
|
||||
queue.push({6});
|
||||
queue.before_transition(22);
|
||||
assets_alive = false;
|
||||
});
|
||||
check(invoked == 2 && destroyed[4] == 1 && destroyed[5] == 1 && destroyed[6] == 1,
|
||||
"Cancel pending and in-flight batches without invoking or leaking old callbacks");
|
||||
queue.before_transition(16); assets_alive = true;
|
||||
queue.push({7}); queue.push({8});
|
||||
try { queue.deliver([](const Request&, std::uintptr_t) { throw std::runtime_error("delivery failed"); }); }
|
||||
catch (const std::runtime_error&) {}
|
||||
check(destroyed[7] == 1 && destroyed[8] == 1, "Failed delivery must release the entire retained batch");
|
||||
queue.push({9}); queue.before_transition(24); queue.before_transition(4); queue.before_transition(13);
|
||||
queue.deliver(invoke);
|
||||
check(!queue.available() && destroyed[9] == 1 && invoked == 2, "Shutdown cannot resume callback delivery");
|
||||
}
|
||||
}
|
||||
int main() {
|
||||
try { regression(); std::puts("Native callback lifetime regressions passed."); return 0; }
|
||||
catch (const std::exception& error) { std::fprintf(stderr, "%s\n", error.what()); return 1; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include "native_data.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::game {
|
||||
// Copying a native delegate owns its optional binding, but not the script's
|
||||
// asset-backed function descriptor. Cancel deferred work before that world
|
||||
// unloads, including work already moved into a reentrant delivery batch.
|
||||
template<class Request> class NativeCallbackQueue {
|
||||
std::vector<Request> pending_;
|
||||
std::uint64_t generation_{};
|
||||
bool blocked_{}, shutdown_{};
|
||||
public:
|
||||
bool available() const { return !blocked_ && pending_.size() < 32; }
|
||||
bool empty() const { return pending_.empty(); }
|
||||
void push(Request request) { pending_.push_back(std::move(request)); }
|
||||
template<class Deliver> void deliver(Deliver&& invoke) {
|
||||
if (blocked_) return;
|
||||
const auto generation = generation_;
|
||||
struct Batch {
|
||||
std::vector<Request> requests;
|
||||
~Batch() {
|
||||
for (auto& request : requests) {
|
||||
NativeDelegateGuard owned;
|
||||
owned.value = request.callback;
|
||||
}
|
||||
}
|
||||
} pending{std::exchange(pending_, {})};
|
||||
for (auto& request : pending.requests) {
|
||||
NativeDelegateGuard owned;
|
||||
owned.value = std::exchange(request.callback, 0);
|
||||
if (!blocked_ && generation == generation_) invoke(request, owned.value);
|
||||
}
|
||||
}
|
||||
void before_transition(unsigned next) {
|
||||
if (next == 3 || next == 14 || next == 22 || next == 24 || next == 25) {
|
||||
blocked_ = true;
|
||||
shutdown_ |= next == 24 || next == 25;
|
||||
++generation_;
|
||||
auto pending = std::exchange(pending_, {});
|
||||
for (auto& request : pending) {
|
||||
NativeDelegateGuard owned;
|
||||
owned.value = request.callback;
|
||||
}
|
||||
} else if (!shutdown_ && (next == 4 || next == 8 || next == 13 || next == 16 || next == 21)) {
|
||||
// Constructors in the next load can subscribe before gameplay starts.
|
||||
blocked_ = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#include "native_data.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/profile.h"
|
||||
|
||||
namespace dingosdk::game {
|
||||
bool initialize_native_data(std::uintptr_t base) {
|
||||
using namespace addr::profile;
|
||||
if (!base) return false;
|
||||
for (const auto& contract : {model_manager_contract, model_create_contract, model_destroy_contract, model_value_contract,
|
||||
model_find_contract, card_model_field_contract, neighborhood_publish_contract, model_lock_contract, model_unlock_contract,
|
||||
news_assign_contract, news_delegate_copy_contract, news_delegate_destroy_contract,
|
||||
news_invoke_contract, news_reference_contract, asset_find_contract}) {
|
||||
std::array<unsigned char, 32> bytes{};
|
||||
if (base > UINTPTR_MAX - contract.rva ||
|
||||
!memory::read(base + contract.rva, bytes) || bytes != contract.bytes) return false;
|
||||
}
|
||||
NativeData next;
|
||||
next.find_asset = reinterpret_cast<decltype(next.find_asset)>(base + asset_find_contract.rva);
|
||||
auto& m = next.models;
|
||||
auto& v = next.values;
|
||||
m.get_model = reinterpret_cast<decltype(m.get_model)>(base + model_manager_contract.rva);
|
||||
m.create = reinterpret_cast<decltype(m.create)>(base + model_create_contract.rva);
|
||||
m.destroy = reinterpret_cast<decltype(m.destroy)>(base + model_destroy_contract.rva);
|
||||
m.value = reinterpret_cast<decltype(m.value)>(base + model_value_contract.rva);
|
||||
m.find = reinterpret_cast<decltype(m.find)>(base + model_find_contract.rva);
|
||||
m.field = reinterpret_cast<decltype(m.field)>(base + card_model_field_contract.rva);
|
||||
m.publish = reinterpret_cast<decltype(m.publish)>(base + neighborhood_publish_contract.rva);
|
||||
m.lock = reinterpret_cast<decltype(m.lock)>(base + model_lock_contract.rva);
|
||||
m.unlock = reinterpret_cast<decltype(m.unlock)>(base + model_unlock_contract.rva);
|
||||
v.assign = reinterpret_cast<decltype(v.assign)>(base + news_assign_contract.rva);
|
||||
v.copy_delegate = reinterpret_cast<decltype(v.copy_delegate)>(base + news_delegate_copy_contract.rva);
|
||||
v.destroy_delegate = reinterpret_cast<decltype(v.destroy_delegate)>(base + news_delegate_destroy_contract.rva);
|
||||
v.invoke = reinterpret_cast<decltype(v.invoke)>(base + news_invoke_contract.rva);
|
||||
v.null_reference = reinterpret_cast<decltype(v.null_reference)>(base + news_reference_contract.rva);
|
||||
native_data() = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dingosdk::game {
|
||||
// All pointers refer to the validated build. Model values are borrowed; callers
|
||||
// hold ModelWriteLock across reads and synchronous publication.
|
||||
struct NativeModelFunctions {
|
||||
using Lock = void (*)(std::uintptr_t);
|
||||
std::uintptr_t (*get_model)(std::uint32_t){};
|
||||
std::uint64_t (*create)(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint32_t, bool, std::uint8_t){};
|
||||
std::uintptr_t (*value)(std::uintptr_t, std::uint64_t, std::uint64_t, std::uint64_t){};
|
||||
std::uint64_t (*find)(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint32_t, std::uint8_t){};
|
||||
std::uint64_t (*field)(std::uintptr_t, std::uint64_t, unsigned, std::uint32_t, bool){};
|
||||
bool (*publish)(std::uintptr_t, std::uint64_t, std::uintptr_t, const void*){};
|
||||
Lock lock{}, unlock{};
|
||||
void* (*destroy)(std::uintptr_t, void*, std::uint64_t, std::uint8_t, bool, bool){};
|
||||
};
|
||||
struct NativeValueFunctions {
|
||||
void* (*assign)(void*, const char*, std::uint32_t){};
|
||||
void (*copy_delegate)(void*, const void*){};
|
||||
void (*destroy_delegate)(void*){};
|
||||
void (*invoke)(const void*, const void*){};
|
||||
void* (*null_reference)(void*, std::uintptr_t){};
|
||||
};
|
||||
struct NativeData {
|
||||
NativeModelFunctions models;
|
||||
NativeValueFunctions values;
|
||||
// Loaded named assets only; native lookup locks the asset registries.
|
||||
std::uintptr_t (*find_asset)(std::uint16_t, const char*){};
|
||||
};
|
||||
inline NativeData& native_data() { static NativeData value; return value; }
|
||||
// Runs before publishing active providers or enabling their hooks.
|
||||
bool initialize_native_data(std::uintptr_t base);
|
||||
|
||||
inline std::uint32_t native_name_hash(std::string_view name) noexcept {
|
||||
std::uint32_t hash = 5381;
|
||||
for (const unsigned char c : name) hash = hash * 33 ^ c;
|
||||
return hash;
|
||||
}
|
||||
|
||||
class ModelWriteLock {
|
||||
NativeModelFunctions::Lock unlock_;
|
||||
std::uintptr_t address_;
|
||||
public:
|
||||
explicit ModelWriteLock(std::uintptr_t manager, const NativeModelFunctions& functions = native_data().models)
|
||||
: unlock_(functions.unlock), address_(manager + 0xf20) {
|
||||
if (!manager || manager > UINTPTR_MAX - 0xf20 || !functions.lock || !unlock_)
|
||||
throw std::runtime_error("Native model lock is unavailable");
|
||||
functions.lock(address_);
|
||||
}
|
||||
~ModelWriteLock() { unlock_(address_); }
|
||||
ModelWriteLock(const ModelWriteLock&) = delete;
|
||||
ModelWriteLock& operator=(const ModelWriteLock&) = delete;
|
||||
};
|
||||
|
||||
// Holds one owned native callback. Raw value transfers are explicit at the
|
||||
// asynchronous queue boundary; this guard must never be copied.
|
||||
class NativeDelegateGuard {
|
||||
void (*destroy_)(void*) = native_data().values.destroy_delegate;
|
||||
public:
|
||||
std::uintptr_t value{};
|
||||
NativeDelegateGuard() = default;
|
||||
~NativeDelegateGuard() { if (value && destroy_) destroy_(&value); }
|
||||
NativeDelegateGuard(const NativeDelegateGuard&) = delete;
|
||||
NativeDelegateGuard& operator=(const NativeDelegateGuard&) = delete;
|
||||
std::uintptr_t release() noexcept { const auto result = value; value = 0; return result; }
|
||||
};
|
||||
|
||||
// Frostbite's counted-array ABI, distinct from EASTL vectors and strings.
|
||||
// This storage is borrowed only for calls that synchronously copy its values.
|
||||
template<class T, std::size_t N> struct NativeArrayStorage {
|
||||
static_assert(N <= UINT32_MAX && alignof(T) <= 8);
|
||||
std::uint32_t capacity{static_cast<std::uint32_t>(N)}, count{};
|
||||
std::array<T, N> values{};
|
||||
};
|
||||
template<class T> bool native_array(const void* wrapper, std::uintptr_t& data,
|
||||
std::uint32_t& count, std::uint32_t maximum) {
|
||||
static_assert(std::is_trivially_copyable_v<T>);
|
||||
std::uintptr_t pointer{}; std::uint32_t size{};
|
||||
data = 0; count = 0;
|
||||
if (!memory::read(reinterpret_cast<std::uintptr_t>(wrapper), pointer) || pointer < 4 ||
|
||||
!memory::read(pointer - 4, size)) return false;
|
||||
size &= 0x7fffffff;
|
||||
if (size > maximum || pointer > UINTPTR_MAX - static_cast<std::uintptr_t>(size) * sizeof(T))
|
||||
return false;
|
||||
data = pointer; count = size;
|
||||
return true;
|
||||
}
|
||||
} // namespace dingosdk::game
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::game {
|
||||
// Native eastl string: 16 bytes of inline data/heap metadata and allocator.
|
||||
// These are borrowed only during Cache(), which copies both strings itself.
|
||||
struct NativeStringView {
|
||||
std::array<std::byte, 16> storage{};
|
||||
std::uintptr_t allocator{};
|
||||
explicit NativeStringView(const std::string& value) {
|
||||
if (value.size() < 16) {
|
||||
std::memcpy(storage.data(), value.data(), value.size());
|
||||
storage[15] = static_cast<std::byte>(15 - value.size());
|
||||
} else {
|
||||
const auto pointer = value.c_str();
|
||||
const auto size = static_cast<std::uint32_t>(value.size());
|
||||
const auto capacity = size | 0x80000000;
|
||||
std::memcpy(storage.data(), &pointer, 8);
|
||||
std::memcpy(storage.data() + 8, &size, 4);
|
||||
std::memcpy(storage.data() + 12, &capacity, 4);
|
||||
}
|
||||
}
|
||||
};
|
||||
static_assert(sizeof(NativeStringView) == 0x18);
|
||||
static_assert(offsetof(NativeStringView, allocator) == 0x10);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::game {
|
||||
// Copy a bounded ASCII identifier from the supported build's EASTL string.
|
||||
// Empty names are valid (the park notification uses them to clear a lot).
|
||||
// Call only while the native callback owns the borrowed string's lifetime.
|
||||
inline bool read_native_identifier(std::uintptr_t address, std::string& result) {
|
||||
result.clear();
|
||||
std::array<unsigned char, 24> data{}, checked{};
|
||||
if (!memory::read(address, data)) return false;
|
||||
std::array<char, 256> text{};
|
||||
std::uint32_t length{};
|
||||
if (data[15] & 0x80) {
|
||||
std::uintptr_t pointer{};
|
||||
std::uint32_t capacity{};
|
||||
std::memcpy(&pointer, data.data(), sizeof(pointer));
|
||||
std::memcpy(&length, data.data() + 8, sizeof(length));
|
||||
std::memcpy(&capacity, data.data() + 12, sizeof(capacity));
|
||||
if (length >= text.size() || length > (capacity & 0x7fffffff) ||
|
||||
!memory::read_bytes(pointer, text.data(), static_cast<std::size_t>(length) + 1)) return false;
|
||||
} else {
|
||||
if (data[15] > 15) return false;
|
||||
length = 15 - data[15];
|
||||
std::memcpy(text.data(), data.data(), length);
|
||||
}
|
||||
if (text[length] || !memory::read(address, checked) || checked != data) return false;
|
||||
for (std::size_t i = 0; i < length; ++i)
|
||||
if (static_cast<unsigned char>(text[i]) < 32 || static_cast<unsigned char>(text[i]) > 126) return false;
|
||||
result.assign(text.data(), length);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// AI skaters spawned from the player blueprint.
|
||||
namespace dingosdk::game::build::v20260929::ai_skaters {
|
||||
// Entity bus: create_bus(owner, parent, 0, 0, 0, 0) creates a child bus and acquire_bus adds
|
||||
// a reference (released with skater_entities::release_reference).
|
||||
inline constexpr std::uintptr_t create_bus = 0x2b7eb40;
|
||||
inline constexpr std::array<unsigned char, 19> create_bus_prefix{
|
||||
0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x48,0x48,0x8b,0xe9,0x41,0x8b,0xd9,0x48,
|
||||
0x8b,0x49,0x48};
|
||||
inline constexpr std::uintptr_t acquire_bus = 0x2b7e530;
|
||||
inline constexpr std::array<unsigned char, 13> acquire_bus_prefix{
|
||||
0xb8,0x01,0x00,0x00,0x00,0xf0,0x0f,0xc1,0x41,0x40,0xff,0xc0,0xc3};
|
||||
// Writes the source data+0xe1 flag into the entity's +0x578 property binding.
|
||||
inline constexpr std::uintptr_t set_property_binding = 0x544020;
|
||||
inline constexpr std::array<unsigned char, 15> set_property_binding_prefix{
|
||||
0x88,0x54,0x24,0x10,0x48,0x83,0xec,0x28,0x48,0x81,0xc1,0x78,0x05,0x00,0x00};
|
||||
// Skater entity vtable slot +0x458. Reads the +0x578 binding unchecked once the skater's physics exists.
|
||||
inline constexpr std::uintptr_t binding_reader = 0x53eba0;
|
||||
// The +0x578 load inside binding_reader.
|
||||
inline constexpr std::uintptr_t binding_read_site = 0x53ebbf;
|
||||
inline constexpr std::array<unsigned char, 7> binding_read_site_prefix{
|
||||
0x48,0x8d,0x8b,0x78,0x05,0x00,0x00};
|
||||
// Skater component physics setter fed from source data+0xe0 after placement.
|
||||
inline constexpr std::uintptr_t physics_source_setter = 0xf9cf40;
|
||||
inline constexpr std::array<unsigned char, 17> physics_source_setter_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x81,0xa0,0x00,0x00,
|
||||
0x00};
|
||||
// Expected code of skater_entities::physics_cb_setter.
|
||||
inline constexpr std::array<unsigned char, 7> physics_cb_setter_prefix{
|
||||
0x88,0x91,0xcb,0x00,0x00,0x00,0xc3};
|
||||
// Native named-asset lookup that searches a domain and its parents. Reference only.
|
||||
inline constexpr std::uintptr_t asset_domain_lookup = 0x41b5780;
|
||||
|
||||
// EATBrainTree component (collection slot 12). brain_tree_factory creates the bt_default
|
||||
// instance (+0x60) through holder +0x40 (brain_tree_holder_vtable); brain_tree_update (vtable
|
||||
// +0x138) ticks the tree only while run flag +0x70 is set.
|
||||
inline constexpr std::uintptr_t brain_tree_vtable = 0x6252b20;
|
||||
inline constexpr std::uintptr_t brain_tree_update = 0x16f5560;
|
||||
inline constexpr std::uintptr_t brain_tree_holder_vtable = 0x6252d60;
|
||||
// Reference only.
|
||||
inline constexpr std::uintptr_t brain_tree_factory = 0x16f23a0;
|
||||
|
||||
// SkaterAIComponent (collection slot 14, created by skater_ai_factory, 0x50 bytes). Its update
|
||||
// (vtable +0x138) creates the 0x2c90-byte AI skater controller at +0x40 (ai_controller_create,
|
||||
// bound to the skater's physics body) and runs it (ai_controller_run) only while +0x48 is set.
|
||||
inline constexpr std::uintptr_t skater_ai_vtable = 0x6170180;
|
||||
inline constexpr std::uintptr_t skater_ai_update = 0xf97630;
|
||||
inline constexpr std::array<unsigned char, 18> skater_ai_update_prefix{
|
||||
0x40,0x53,0x57,0x48,0x83,0xec,0x38,0x80,0x79,0x48,0x00,0x48,0x8b,0xf9,0x48,0x8b,
|
||||
0x59,0x40};
|
||||
// Reference only.
|
||||
inline constexpr std::uintptr_t skater_ai_factory = 0xf881d0;
|
||||
// Reference only.
|
||||
inline constexpr std::uintptr_t ai_controller_create = 0x10373a0;
|
||||
// Reference only.
|
||||
inline constexpr std::uintptr_t ai_controller_run = 0x10461e0;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
namespace dingosdk::game::build::v20260929::atmosphere {
|
||||
struct AtmosphereBinding {
|
||||
unsigned kind; std::uint32_t hash; unsigned cache, size, mask_offset;
|
||||
std::uint32_t mask; unsigned group, asset_offset; bool texture;
|
||||
};
|
||||
// Reference assignment, void(slot*, value): retains value, releases the old one.
|
||||
inline constexpr std::uintptr_t reference_assign = 0x3dd1d70;
|
||||
inline constexpr std::array<Fingerprint,4> atmosphere_sites{{
|
||||
{0x5fcc6f0,{0x41,0x81,0xf8,0xa5,0xef,0xc0,0x0d,0x0f,0x8f,0xb3,0x0a,0x00,0x00,0x0f,0x84,0x76,0x0a,0x00,0x00,0x41,0x81,0xf8,0xaf,0xd5,0x21,0xd4,0x0f,0x8f,0x9c,0x05,0x00,0x00}},
|
||||
{0x5fd29b0,{0x48,0x83,0xec,0x28,0x41,0x81,0xf8,0xb8,0x9b,0xb1,0xfa,0x0f,0x8f,0xdd,0x0e,0x00,0x00,0x0f,0x84,0x99,0x0e,0x00,0x00,0x41,0x81,0xf8,0x6f,0xe1,0x67,0xb5,0x0f,0x8f}},
|
||||
{0x5fd74d0,{0x41,0x81,0xf8,0xd4,0x1a,0xa0,0xe0,0x0f,0x8f,0x41,0x01,0x00,0x00,0x0f,0x84,0x0f,0x01,0x00,0x00,0x41,0x81,0xf8,0xfe,0xcd,0x89,0x8b,0x0f,0x84,0xd3,0x00,0x00,0x00}},
|
||||
{reference_assign,{0x4c,0x8b,0x01,0x4c,0x8b,0xc9,0x49,0x8b,0xc8,0x48,0x8b,0xc2,0x48,0x83,0xe1,0xfb,0x48,0x83,0xe0,0xfb,0x48,0x3b,0xc8,0x74,0x31,0xb8,0x00,0x20,0x00,0x00,0x48,0x85}},
|
||||
}};
|
||||
// Verified native property dispatch + PropertyOverrides hash dispatch.
|
||||
// Live component offsets, not serialized EBX reflection offsets.
|
||||
inline constexpr std::array<AtmosphereBinding, 194> atmosphere_bindings{{
|
||||
{0,0x8ba423e4,0x26b,1,0x198,0x1,0,0x29e,false}, // Fog.Enable
|
||||
{0,0x79cf6d43,0x1f4,4,0x198,0x2,0,0x228,false}, // Fog.FogDistanceMultiplier
|
||||
{0,0x27e5432c,0x267,1,0x198,0x4,0,0x29b,false}, // Fog.FogGradientEnable
|
||||
{0,0xdc0efa5,0x1bc,4,0x198,0x8,0,0x1d0,false}, // Fog.Start
|
||||
{0,0xb87a32a,0x1d0,4,0x198,0x10,0,0x234,false}, // Fog.End
|
||||
{0,0xcb01792,0x130,16,0x198,0x20,0,0x170,false}, // Fog.Curve
|
||||
{0,0xfc54d655,0x266,1,0x198,0x40,0,0x29d,false}, // Fog.FogGradientHeightFadeEnable
|
||||
{0,0xbd8ad483,0x1d8,4,0x198,0x80,0,0x1e0,false}, // Fog.FadeStart
|
||||
{0,0x54cd410c,0x190,4,0x198,0x100,0,0x1cc,false}, // Fog.FadeEnd
|
||||
{0,0x59dd7757,0x264,1,0x198,0x200,0,0x2a5,false}, // Fog.FogColorEnable
|
||||
{0,0x15926076,0x110,16,0x198,0x400,0,0x180,false}, // Fog.FogColor
|
||||
{0,0x66de58b6,0x258,4,0x198,0x800,0,0x290,false}, // Fog.FogColorStart
|
||||
{0,0x39381539,0x19c,4,0x198,0x1000,0,0x280,false}, // Fog.FogColorEnd
|
||||
{0,0x67eaf0c1,0x140,16,0x198,0x2000,0,0x160,false}, // Fog.FogColorCurve
|
||||
{0,0x39b0a478,0x200,4,0x198,0x4000,0,0x230,false}, // Fog.TransparencyFadeClamp
|
||||
{0,0x3acdca8b,0x22c,4,0x198,0x8000,0,0x25c,false}, // Fog.TransparencyFadeStart
|
||||
{0,0x3b4b1304,0x250,4,0x198,0x10000,0,0x28c,false}, // Fog.TransparencyFadeEnd
|
||||
{0,0x39bd237c,0x150,16,0x198,0x20000,0,0x140,false}, // Fog.TransparencyFadeCurve
|
||||
{0,0x1edbc483,0x26e,1,0x198,0x40000,0,0x298,false}, // Fog.ForwardLightScatteringEnabled
|
||||
{0,0x2ecc6f6e,0x1f0,4,0x198,0x80000,0,0x1fc,false}, // Fog.ForwardLightScatteringPhaseG
|
||||
{0,0x2411ef23,0x1a4,4,0x198,0x100000,0,0x250,false}, // Fog.ForwardLightScatteringStrength
|
||||
{0,0x2ee34dbb,0x120,16,0x198,0x200000,0,0x190,false}, // Fog.ForwardLightScatteringColor
|
||||
{0,0x6650e9bf,0x25c,4,0x198,0x400000,0,0x1e8,false}, // Fog.ForwardLightScatteringPresence
|
||||
{0,0xad856027,0x204,4,0x198,0x800000,0,0x1f0,false}, // Fog.ForwardLightScatteringMaxBlurLength
|
||||
{0,0x1f83add7,0x1c8,4,0x198,0x1000000,0,0x1e4,false}, // Fog.ForwardLightScatteringExtinction
|
||||
{0,0xe3d2aa6f,0x1b4,4,0x198,0x2000000,0,0x260,false}, // Fog.ForwardLightScatteringSmoothness
|
||||
{0,0xd89cb4bc,0x230,4,0x198,0x4000000,0,0x294,false}, // Fog.ForwardLightScatteringAttenuationType
|
||||
{0,0x577b1355,0x276,1,0x198,0x8000000,0,0x2a6,false}, // Fog.HeightFogEnable
|
||||
{0,0x9497b25c,0x214,4,0x198,0x10000000,0,0x214,false}, // Fog.HeightFogFollowCamera
|
||||
{0,0x88bb3da4,0x1f8,4,0x198,0x20000000,0,0x26c,false}, // Fog.HeightFogAltitude
|
||||
{0,0x9dd60e99,0x23c,4,0x198,0x40000000,0,0x20c,false}, // Fog.HeightFogDepth
|
||||
{0,0x7f3a9c6d,0x1d4,4,0x198,0x80000000,0,0x218,false}, // Fog.HeightFogVisibilityRange
|
||||
{0,0x9d5b5d74,0x188,4,0x1dc,0x1,1,0x22c,false}, // Fog.HeightFogStart
|
||||
{0,0x87c8f1fb,0x18c,4,0x1dc,0x2,1,0x200,false}, // Fog.HeightFogEnd
|
||||
{0,0x9e6c2583,0xe0,16,0x1dc,0x4,1,0x130,false}, // Fog.HeightFogCurve
|
||||
{0,0xdb5f6bd1,0x26d,1,0x1dc,0x8,1,0x2a8,false}, // Fog.ParticipatingMediaEnable
|
||||
{0,0xf513e058,0x268,1,0x1dc,0x10,1,0x29a,false}, // Fog.ParticipatingMediaSun
|
||||
{0,0xde58529a,0x240,4,0x1dc,0x20,1,0x268,false}, // Fog.SunLightScatteringIntensity
|
||||
{0,0x4375eef0,0x272,1,0x1dc,0x40,1,0x29f,false}, // Fog.ParticipatingMediaLocalLights
|
||||
{0,0xcb29387f,0x238,4,0x1dc,0x80,1,0x21c,false}, // Fog.LocalLightScatteringIntensity
|
||||
{0,0x8a3eea60,0x265,1,0x1dc,0x100,1,0x2aa,false}, // Fog.ParticipatingMediaExtinctionEnable
|
||||
{0,0x8cba4079,0x275,1,0x1dc,0x200,1,0x2a1,false}, // Fog.ParticipatingMediaEmissiveAndAmbientEnable
|
||||
{0,0xf5cac355,0x271,1,0x1dc,0x400,1,0x2a9,false}, // Fog.LocalLightVolumetricShadowEnable
|
||||
{0,0x77d5c80e,0x26f,1,0x1dc,0x800,1,0x29c,false}, // Fog.SunVolumetricShadowEnable
|
||||
{0,0x16aeb8b3,0x274,1,0x1dc,0x1000,1,0x2a4,false}, // Fog.CloudShadowEnable
|
||||
{0,0x36c7a2,0x180,8,0x1dc,0x2000,1,0x1c0,false}, // Fog.CloudShadowIntensityRange
|
||||
{0,0xfc4c6567,0x270,1,0x1dc,0x4000,1,0x2a0,false}, // Fog.LocalParticipatingMediaVolumesEnable
|
||||
{0,0x95105574,0x1e4,4,0x1dc,0x8000,1,0x288,false}, // Fog.ParticipatingMediaDefaultPhase
|
||||
{0,0x502dc356,0x1c0,4,0x1dc,0x10000,1,0x220,false}, // Fog.ParticipatingMediaFadeStart
|
||||
{0,0xe5467fd9,0x24c,4,0x1dc,0x20000,1,0x270,false}, // Fog.ParticipatingMediaFadeEnd
|
||||
{0,0x513e7b61,0x100,16,0x1dc,0x40000,1,0x120,false}, // Fog.ParticipatingMediaFadeCurve
|
||||
{0,0x6fe1efa3,0x273,1,0x1dc,0x80000,1,0x299,false}, // Fog.ParticipatingMediaFromGlobalFog
|
||||
{0,0x28d795d3,0x40,80,0x1dc,0x100000,1,0x80,false}, // Fog.DepthFogParticipatingMedia
|
||||
{0,0x9c044441,0x90,80,0x1dc,0x200000,1,0xd0,false}, // Fog.HeightFogParticipatingMedia
|
||||
{0,0x44729613,0x1cc,4,0x1dc,0x400000,1,0x24c,false}, // Fog.ParticipatingMediaTemporalMethod
|
||||
{0,0xbc5798e9,0x1e0,4,0x1dc,0x800000,1,0x1c8,false}, // Fog.ParticipatingMediaTemporalFilterStrength
|
||||
{0,0xd770d72e,0x1ac,4,0x1dc,0x2000000,1,0x1d8,false}, // Fog.LocalVolumeViewDistance
|
||||
{0,0x205b48c9,0x1ec,4,0x1dc,0x4000000,1,0x1f8,false}, // Fog.LocalVolumeReflectionViewDistance
|
||||
{0,0xd500e4a5,0x269,1,0x1dc,0x8000000,1,0x2a7,false}, // Fog.DepthSliceUseLogDistribution
|
||||
{0,0x2af7565a,0x20c,4,0x1dc,0x10000000,1,0x210,false}, // Fog.DepthSliceLogGradient
|
||||
{0,0xadf24311,0x194,4,0x1dc,0x20000000,1,0x238,false}, // Fog.DepthSliceDistributionExponent
|
||||
{0,0xfe32028f,0x1b0,4,0x1dc,0x40000000,1,0x204,false}, // Fog.LocalVolumeFarFadeLength
|
||||
{0,0x753332a0,0xf0,16,0x1dc,0x80000000,1,0x150,false}, // Fog.LocalVolumeMediaFarFadeCurve
|
||||
{0,0x45b60c5e,0x1fc,4,0x254,0x1,2,0x254,false}, // Fog.ExtinctionCascadeBaseVoxelSize
|
||||
{0,0xd589e34,0x1a8,4,0x254,0x2,2,0x264,false}, // Fog.ExtinctionCascadeVoxelSizeCascadeFactor
|
||||
{0,0x786b1ac8,0x224,4,0x254,0x4,2,0x27c,false}, // Fog.ExtinctionCascadeResolution
|
||||
{0,0x7c16fbb5,0x220,4,0x254,0x8,2,0x248,false}, // Fog.VolumetricShadowmapResolution
|
||||
{0,0x68c3a84c,0x228,4,0x254,0x10,2,0x1dc,false}, // Fog.VolumetricShadowmapMaxCount
|
||||
{0,0xd4590ca,0x248,4,0x254,0x20,2,0x258,false}, // Fog.SunVolumetricShadowSampleCount
|
||||
{0,0xdb330072,0x21c,4,0x254,0x40,2,0x274,false}, // Fog.SunVolumetricShadowSampleStrech
|
||||
{0,0x1346a541,0x26a,1,0x254,0x80,2,0x2a2,false}, // Fog.SunExponentialShadowMapEnable
|
||||
{0,0x3b8714b3,0x160,16,0x254,0x100,2,0x1a0,false}, // Fog.ResolutionScale
|
||||
{0,0xc78b3f8b,0x170,16,0x254,0x200,2,0x1b0,false}, // Fog.DepthSlices
|
||||
{0,0xda640834,0x1c4,4,0x254,0x400,2,0x244,false}, // Fog.ReflectionResolutionScale
|
||||
{0,0x6481fa8c,0x260,4,0x254,0x800,2,0x224,false}, // Fog.ReflectionDepthSlices
|
||||
{0,0x156b4de7,0x26c,1,0x254,0x1000,2,0x2a3,false}, // Fog.GlobalVolumeEnable
|
||||
{0,0xbf43aa08,0x210,4,0x254,0x2000,2,0x284,false}, // Fog.GlobalVolumeViewDistance
|
||||
{0,0xa571ca6f,0x1b8,4,0x254,0x4000,2,0x1ec,false}, // Fog.GlobalVolumeReflectionViewDistance
|
||||
{0,0x19a9741a,0x1e8,4,0x254,0x8000,2,0x240,false}, // Fog.DepthSliceDistributionExponentGlobal
|
||||
{0,0xe6199207,0x208,4,0x254,0x10000,2,0x1d4,false}, // Fog.DepthSliceDensityRatioLocalGlobal
|
||||
{1,0x8ba423e4,0x2dc,1,0x1f0,0x1,0,0x2d6,false}, // Sky.Enable
|
||||
{1,0x8bfc33e9,0x2df,1,0x1f0,0x2,0,0x2d3,false}, // Sky.DrawSkyGeo
|
||||
{1,0xb930a227,0x2e0,1,0x1f0,0x4,0,0x2da,false}, // Sky.CaptureForceLowerColorEnabled
|
||||
{1,0xdefff3c2,0x50,16,0x1f0,0x8,0,0x80,false}, // Sky.CaptureForceLowerColor
|
||||
{1,0xb18176bc,0x1e8,4,0x1f0,0x10,0,0x1b8,false}, // Sky.SkyType
|
||||
{1,0x9aff5c27,0x190,4,0x1f0,0x40,0,0x1ac,false}, // Sky.LuminanceScale
|
||||
{1,0x3bb33fbd,0x188,8,0x1f0,0x80,0,0x198,true}, // Sky.SkyGradientTexture
|
||||
{1,0x28fb890e,0x2ac,4,0x1f0,0x100,0,0x228,false}, // Sky.AlphaOutput
|
||||
{1,0x82e1c220,0x2e1,1,0x1f0,0x200,0,0x2d4,false}, // Sky.AllowEnergyIncreaseInNormalization
|
||||
{1,0xd0a1ba6,0x224,4,0x1f0,0x400,0,0x224,false}, // Sky.HdriRotation
|
||||
{1,0x239bd7cd,0x120,8,0x1f0,0x800,0,0x160,true}, // Sky.HdriTexture
|
||||
{1,0x801c6248,0x208,4,0x1f0,0x1000,0,0x230,false}, // Sky.SunSize
|
||||
{1,0x83ae3355,0x220,4,0x1f0,0x2000,0,0x1d0,false}, // Sky.SunScale
|
||||
{1,0x876feef0,0x1c4,4,0x1f0,0x4000,0,0x2c4,false}, // Sky.PanoramicUVMinX
|
||||
{1,0x8770112e,0x228,4,0x1f0,0x8000,0,0x2bc,false}, // Sky.PanoramicUVMaxX
|
||||
{1,0x876feef1,0x2cc,4,0x1f0,0x10000,0,0x20c,false}, // Sky.PanoramicUVMinY
|
||||
{1,0x8770112f,0x274,4,0x1f0,0x20000,0,0x2b0,false}, // Sky.PanoramicUVMaxY
|
||||
{1,0xfab19bb8,0x1ac,4,0x1f0,0x40000,0,0x274,false}, // Sky.PanoramicTileFactor
|
||||
{1,0x3a72c6f5,0x2b8,4,0x1f0,0x80000,0,0x1b0,false}, // Sky.PanoramicRotation
|
||||
{1,0x9ffb073e,0x150,8,0x1f0,0x100000,0,0x188,true}, // Sky.PanoramicTexture
|
||||
{1,0x86cd3d0a,0x170,8,0x1f0,0x200000,0,0x1a0,true}, // Sky.PanoramicAlphaTexture
|
||||
{1,0xe11089cd,0x204,4,0x1f0,0x400000,0,0x2b4,false}, // Sky.PanoramicAlphaScale
|
||||
{1,0x6de9a4c,0x1dc,4,0x1f0,0x800000,0,0x264,false}, // Sky.PanoramicAlphaBias
|
||||
{1,0xaa727174,0x2e3,1,0x1f0,0x1000000,0,0x2d7,false}, // Sky.SkyGradientFollowsPanoramicUVs
|
||||
{1,0x2fe80f92,0x2bc,4,0x1f0,0x2000000,0,0x240,false}, // Sky.FlowPeriod
|
||||
{1,0x2d112cd4,0x198,4,0x1f0,0x4000000,0,0x22c,false}, // Sky.FlowDistance
|
||||
{1,0xf0115992,0x26c,4,0x1f0,0x8000000,0,0x1bc,false}, // Sky.FlowDirection
|
||||
{1,0x9f392004,0x258,4,0x1f0,0x10000000,0,0x2cc,false}, // Sky.FlowHeightMaskScale
|
||||
{1,0x907b0fa5,0x238,4,0x1f0,0x20000000,0,0x2a4,false}, // Sky.FlowHeightMaskBias
|
||||
{1,0xe1fcc07c,0x138,8,0x1f0,0x40000000,0,0x178,true}, // Sky.FlowMaskTexture
|
||||
{1,0x7c0164a2,0x110,16,0x1f0,0x80000000,0,0xe0,false}, // Sky.CloudLayerSunColor
|
||||
{1,0x5084247c,0x128,8,0x290,0x1,1,0x170,true}, // Sky.CloudLayerMaskTexture
|
||||
{1,0x7ad9af56,0x2b0,4,0x290,0x2,1,0x25c,false}, // Sky.CloudLayer1Altitude
|
||||
{1,0x4412351f,0x248,4,0x290,0x4,1,0x2c8,false}, // Sky.CloudLayer1TileFactor
|
||||
{1,0x68e78212,0x24c,4,0x290,0x8,1,0x284,false}, // Sky.CloudLayer1Rotation
|
||||
{1,0xb661eb21,0x1f4,4,0x290,0x10,1,0x1a8,false}, // Sky.CloudLayer1Speed
|
||||
{1,0x6c30401f,0x1d8,4,0x290,0x20,1,0x1c4,false}, // Sky.CloudLayer1SunLightIntensity
|
||||
{1,0x26c0fa8f,0x218,4,0x290,0x40,1,0x1b4,false}, // Sky.CloudLayer1SunLightPower
|
||||
{1,0x543001af,0x2d0,4,0x290,0x80,1,0x294,false}, // Sky.CloudLayer1AmbientLightIntensity
|
||||
{1,0xb54ad2db,0xf0,16,0x290,0x100,1,0xb0,false}, // Sky.CloudLayer1Color
|
||||
{1,0x717edc66,0x200,4,0x290,0x200,1,0x1d4,false}, // Sky.CloudLayer1AlphaMul
|
||||
{1,0xdc02bbf9,0x158,8,0x290,0x400,1,0x190,true}, // Sky.CloudLayer1Texture
|
||||
{1,0x3c039f87,0x1a0,4,0x290,0x800,1,0x210,false}, // Sky.CloudLayer1Absorption
|
||||
{1,0xc0c08be0,0x1cc,4,0x290,0x1000,1,0x24c,false}, // Sky.CloudLayer1Scattering
|
||||
{1,0xb6ea9429,0x2a0,4,0x290,0x2000,1,0x23c,false}, // Sky.CloudLayer1Phase
|
||||
{1,0x81b98690,0x27c,4,0x290,0x4000,1,0x21c,false}, // Sky.CloudLayer1Thickness
|
||||
{1,0x775fd355,0x214,4,0x290,0x8000,1,0x1f0,false}, // Sky.CloudLayer2Altitude
|
||||
{1,0x9dfca59c,0x1f8,4,0x290,0x10000,1,0x298,false}, // Sky.CloudLayer2TileFactor
|
||||
{1,0x7d862991,0x280,4,0x290,0x20000,1,0x204,false}, // Sky.CloudLayer2Rotation
|
||||
{1,0xab23bbc2,0x1c8,4,0x290,0x40000,1,0x220,false}, // Sky.CloudLayer2Speed
|
||||
{1,0x2eebddbc,0x1b0,4,0x290,0x80000,1,0x290,false}, // Sky.CloudLayer2SunLightIntensity
|
||||
{1,0x5742212c,0x254,4,0x290,0x100000,1,0x28c,false}, // Sky.CloudLayer2SunLightPower
|
||||
{1,0x3a9a798c,0x20c,4,0x290,0x200000,1,0x2c0,false}, // Sky.CloudLayer2AmbientLightIntensity
|
||||
{1,0xaa0d9238,0x80,16,0x290,0x400000,1,0x110,false}, // Sky.CloudLayer2Color
|
||||
{1,0x6e2608e5,0x2a4,4,0x290,0x800000,1,0x1c0,false}, // Sky.CloudLayer2AlphaMul
|
||||
{1,0x47a4ed1a,0x130,8,0x290,0x1000000,1,0x168,true}, // Sky.CloudLayer2Texture
|
||||
{1,0x6e341204,0x1a4,4,0x290,0x2000000,1,0x2ac,false}, // Sky.CloudLayer2Absorption
|
||||
{1,0x9c4e05a3,0x29c,4,0x290,0x4000000,1,0x1ec,false}, // Sky.CloudLayer2Scattering
|
||||
{1,0xab41e2ca,0x2c8,4,0x290,0x8000000,1,0x234,false}, // Sky.CloudLayer2Phase
|
||||
{1,0x57d9b473,0x28c,4,0x290,0x10000000,1,0x270,false}, // Sky.CloudLayer2Thickness
|
||||
{1,0x97d7f483,0x140,8,0x290,0x20000000,1,0x180,true}, // Sky.StaticEnvmapTexture
|
||||
{1,0x3ed079c4,0x284,4,0x290,0x40000000,1,0x1c8,false}, // Sky.StaticEnvmapScale
|
||||
{1,0x20dc0c97,0x250,4,0x290,0x80000000,1,0x2b8,false}, // Sky.EarthRadius
|
||||
{1,0x63f98f93,0x1d0,4,0x1e0,0x1,2,0x254,false}, // Sky.AtmosphereRadius
|
||||
{1,0xe5491897,0x2b4,4,0x1e0,0x2,2,0x208,false}, // Sky.MieScatteringCoefficient
|
||||
{1,0x7c801b03,0x1b8,4,0x1e0,0x4,2,0x29c,false}, // Sky.MieG
|
||||
{1,0xf628cee6,0x1fc,4,0x1e0,0x8,2,0x1f8,false}, // Sky.MieExtinctionCoefficientRelation
|
||||
{1,0x2e9d1ae3,0x1b4,4,0x1e0,0x10,2,0x214,false}, // Sky.ScaleHeightMie
|
||||
{1,0xeb66fed3,0xd0,16,0x1e0,0x20,2,0x90,false}, // Sky.RayleighScatteringCoefficient
|
||||
{1,0x689fea8b,0x278,4,0x1e0,0x40,2,0x1e4,false}, // Sky.RayleighScatteringCoefficientScale
|
||||
{1,0xd47a7722,0x2c0,4,0x1e0,0x80,2,0x250,false}, // Sky.RayleighExtinctionCoefficientRelation
|
||||
{1,0x92d3847,0x22c,4,0x1e0,0x100,2,0x260,false}, // Sky.ScaleHeightRayleigh
|
||||
{1,0xf48e117,0x2d9,1,0x1e0,0x200,2,0x2d0,false}, // Sky.UseOzone
|
||||
{1,0x831c0e0c,0x1e4,4,0x1e0,0x400,2,0x288,false}, // Sky.OzonePercentage
|
||||
{1,0x40d17298,0x2d8,1,0x1e0,0x800,2,0x2d5,false}, // Sky.UseAerialPerspective
|
||||
{1,0xcd0471a3,0x2a8,4,0x1e0,0x1000,2,0x1cc,false}, // Sky.AerialPerspectiveScale
|
||||
{1,0xcb1a4494,0x25c,4,0x1e0,0x2000,2,0x27c,false}, // Sky.AerialPerspectiveIntensity
|
||||
{1,0x504cf03d,0x270,4,0x1e0,0x4000,2,0x26c,false}, // Sky.AerialPerspectiveDithering
|
||||
{1,0x95a82c6c,0x23c,4,0x1e0,0x8000,2,0x1f4,false}, // Sky.AerialPerspectiveMaxDistance
|
||||
{1,0x51914cd7,0xc0,16,0x1e0,0x10000,2,0xa0,false}, // Sky.Light1Color
|
||||
{1,0xe5a3a4c5,0x210,4,0x1e0,0x20000,2,0x1e8,false}, // Sky.Light1Intensity
|
||||
{1,0x2335c4fd,0x2dd,1,0x1e0,0x40000,2,0x2d2,false}, // Sky.Light1FollowOutdoorLight
|
||||
{1,0xb567e16f,0x2da,1,0x1e0,0x80000,2,0x2d8,false}, // Sky.Light1TakesColorFromOutdoorLight
|
||||
{1,0x7ea12c1b,0x234,4,0x1e0,0x100000,2,0x248,false}, // Sky.Light1RotX
|
||||
{1,0x7ea12c1a,0x294,4,0x1e0,0x200000,2,0x258,false}, // Sky.Light1RotY
|
||||
{1,0x608d0ff7,0x2db,1,0x1e0,0x400000,2,0x2d1,false}, // Sky.UseLightSource2
|
||||
{1,0x46540c34,0x40,16,0x1e0,0x800000,2,0xc0,false}, // Sky.Light2Color
|
||||
{1,0x3004c626,0x1a8,4,0x1e0,0x1000000,2,0x1dc,false}, // Sky.Light2Intensity
|
||||
{1,0x7e4d0358,0x288,4,0x1e0,0x2000000,2,0x2a8,false}, // Sky.Light2RotX
|
||||
{1,0x7e4d0359,0x19c,4,0x1e0,0x4000000,2,0x278,false}, // Sky.Light2RotY
|
||||
{1,0xf52bb58,0x2e2,1,0x1e0,0x8000000,2,0x2db,false}, // Sky.UseNoise
|
||||
{1,0xccf636a8,0x194,4,0x1e0,0x10000000,2,0x1e0,false}, // Sky.FogStartDistance
|
||||
{1,0xd1b50b0e,0x60,16,0x1e0,0x20000000,2,0x100,false}, // Sky.RayleighPolarization
|
||||
{1,0x4fe711ca,0xa0,16,0x1e0,0x40000000,2,0x130,false}, // Sky.MiePolarization
|
||||
{1,0xea7beb3b,0x90,16,0x1e0,0x80000000,2,0x150,false}, // Sky.OutdoorLightScale
|
||||
{1,0xda190bd0,0x2de,1,0x298,0x1,3,0x2d9,false}, // Sky.DrawSunDisc
|
||||
{1,0xa71db073,0x70,16,0x298,0x2,3,0x120,false}, // Sky.ForwardScatteringDepthVisibility
|
||||
{1,0x77148335,0x1ec,4,0x298,0x4,3,0x244,false}, // Sky.ForwardScatteringStartDepth
|
||||
{1,0xec82bf5a,0x260,4,0x298,0x8,3,0x200,false}, // Sky.ForwardScatteringEndDepth
|
||||
{1,0x4a1d65bd,0x21c,4,0x298,0x10,3,0x238,false}, // Sky.ForwardScatteringTakesColorFromOutdoorLight
|
||||
{1,0xf92d1999,0x100,16,0x298,0x20,3,0xf0,false}, // Sky.ForwardScatteringOutdoorLightTint
|
||||
{1,0x7ec79f08,0xe0,16,0x298,0x40,3,0x140,false}, // Sky.HeightFogColorAdd
|
||||
{1,0x57b98fe9,0xb0,16,0x298,0x80,3,0xd0,false}, // Sky.HeightFogColorMult
|
||||
{1,0x63c1b09,0x1d4,4,0x298,0x100,3,0x1fc,false}, // Sky.MinHeightFogTransmittance
|
||||
{2,0xbb9fa0d4,0x40,4,0x62,0x1,0,0x94,false}, // Wind.WindDirection
|
||||
{2,0x1995362e,0x54,4,0x62,0x2,0,0x9c,false}, // Wind.WindDirectionVariationMultiplier
|
||||
{2,0xe0a01ad4,0x5c,4,0x62,0x4,0,0xa0,false}, // Wind.WindStrength
|
||||
{2,0x5543330b,0x58,4,0x62,0x8,0,0x98,false}, // Wind.WindVariationMultiplier
|
||||
{2,0x9993e529,0x50,4,0x62,0x10,0,0x80,false}, // Wind.WindVariationRateMultiplier
|
||||
{2,0x463725d1,0x38,4,0x62,0x20,0,0x88,false}, // Wind.WindMicroVariationMultiplier
|
||||
{2,0x62d41de3,0x48,4,0x62,0x40,0,0x84,false}, // Wind.WindOscillationAmplitude
|
||||
{2,0x8b89cdfe,0x3c,4,0x62,0x80,0,0x90,false}, // Wind.WindOscillationFrequency
|
||||
{2,0xbff3190b,0x4c,4,0x62,0x100,0,0x8c,false}, // Wind.TurbulenceMultiplier
|
||||
{2,0x44bbbf8,0x44,4,0x62,0x200,0,0xa4,false}, // Wind.TurbulenceScale
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::buildkit_limits {
|
||||
// DingoObjectPersistenceSettings vtable (Build Kit object budget and cursor radius).
|
||||
inline constexpr std::uintptr_t object_persistence_settings_vtable = 0x61259a0;
|
||||
// DingoObjectPersistenceSettings TypeInfo; also its settings-manager lookup key.
|
||||
inline constexpr std::uintptr_t object_persistence_settings_type = 0x723a510;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::camera_observer {
|
||||
// Camera callback observed by the camera diagnostics.
|
||||
inline constexpr std::uintptr_t camera_callback = 0x178b690;
|
||||
inline constexpr std::array<unsigned char, 24> camera_callback_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x59,0x18,0x48,0x8b,
|
||||
0x41,0x20,0xc5,0xfa,0x10,0x02,0x48,0x89,0x53,0x60,0x48,0x8d};
|
||||
// Return address of the verified native caller of the camera callback.
|
||||
inline constexpr std::uintptr_t native_caller_return = 0x1728bd5;
|
||||
// Camera callback object.
|
||||
inline constexpr std::uintptr_t callback_vtable = 0x625b768;
|
||||
// The two native camera entry variants. 14178fb00 and 14178fba0 create them
|
||||
// through the same 1417224a0 base constructor; the live stuck-camera capture
|
||||
// selected the alternate one after a camera/editor transition.
|
||||
inline constexpr std::uintptr_t entry_vtable = 0x625c3d8;
|
||||
inline constexpr std::uintptr_t alternate_entry_vtable = 0x625c270;
|
||||
// Callback helper.
|
||||
inline constexpr std::uintptr_t helper_vtable = 0x625b7b0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Native camera, game UI and noclip functions used by the debug controls and party spectate.
|
||||
namespace dingosdk::game::build::v20260929::client_source_spawn {
|
||||
// ClientSkaterSource creator (source). Creates source+0x68 from data+0xd0. Reference only
|
||||
// (ai_skaters mirrors it).
|
||||
inline constexpr std::uintptr_t source_create = 0x51fef0;
|
||||
// uint32 offset of the owning client in a game context.
|
||||
inline constexpr std::uintptr_t context_client_offset = 0x6fc25f0;
|
||||
|
||||
// Free camera.
|
||||
// uint32 native camera local id.
|
||||
inline constexpr std::uintptr_t camera_local_id = 0x73c7ab8;
|
||||
inline constexpr std::uintptr_t camera_controller_vtable = 0x63efab8;
|
||||
// Camera controller vtable slot +0xb8.
|
||||
inline constexpr std::uintptr_t camera_mode_switch = 0x2f2c360;
|
||||
inline constexpr std::array<unsigned char, 24> camera_mode_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x8b,0xda,0x48,0x8b,0xf9,0x39,
|
||||
0x51,0x08,0x74,0x14,0x83,0xfa,0x01,0x77};
|
||||
inline constexpr std::uintptr_t free_camera_vtable = 0x63fc220;
|
||||
// FreeCamera vtable slot +0xd0.
|
||||
inline constexpr std::uintptr_t camera_transform_setter = 0x2f8fa90;
|
||||
inline constexpr std::array<unsigned char, 24> camera_transform_prefix{
|
||||
0xc5,0xfc,0x10,0x02,0xc5,0xfc,0x11,0x81,0x80,0x01,0x00,0x00,0xc5,0xfc,0x10,0x4a,
|
||||
0x20,0xc5,0xfc,0x11,0x89,0xa0,0x01,0x00};
|
||||
// Initializes the camera's degree FOV at +0xac. Reference only.
|
||||
inline constexpr std::uintptr_t camera_fov_init = 0x2f8bb70;
|
||||
// FreeCamera input helpers at +0x1d0 and +0x1d8.
|
||||
inline constexpr std::array<std::uintptr_t, 2> free_camera_helper_vtables{0x63fc1e0, 0x63fc200};
|
||||
// Pointer to the camera view manager.
|
||||
inline constexpr std::uintptr_t view_manager = 0x75cbc48;
|
||||
// Pointer: the registered camera view manager.
|
||||
inline constexpr std::uintptr_t view_manager_registration = 0x75dd288;
|
||||
inline constexpr std::uintptr_t view_manager_vtable = 0x63ece68;
|
||||
// view_manager_vtable slots 0, +8, +0x28 and +0x98.
|
||||
inline constexpr std::array<std::uintptr_t, 4> view_manager_methods{0x2f11470, 0x2f0f700, 0x2f0d7c0, 0x2f0d0b0};
|
||||
inline constexpr std::uintptr_t camera_view_vtable = 0x63ed0a0;
|
||||
|
||||
// Debug UI and noclip.
|
||||
// Pointer to the game UI settings; draw flag at +0x4e.
|
||||
inline constexpr std::uintptr_t ui_settings = 0x7761aa0;
|
||||
inline constexpr std::uintptr_t ui_settings_vtable = 0x65ae378;
|
||||
inline constexpr std::uintptr_t ui_settings_type = 0x77674d0;
|
||||
inline constexpr std::uintptr_t board_physics_vtable = 0x65e67a0;
|
||||
inline constexpr std::uintptr_t rig_physics_vtable = 0x65dabc0;
|
||||
// Skater physics core update; no_bail::bail_core_vtable slot +0x58. Hooked for noclip.
|
||||
inline constexpr std::uintptr_t physics_update = 0x47ddca0;
|
||||
inline constexpr std::array<unsigned char, 32> physics_update_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0xd9,0x48,0x8b,0x89,0xb0,0x03,0x00,0x00,
|
||||
0x48,0x8b,0x01,0xff,0x50,0x18,0x48,0x8b,0x8b,0xf8,0x03,0x00,0x00,0xe8,0xfe,0xd3};
|
||||
// Skater motion target update. Hooked for noclip.
|
||||
inline constexpr std::uintptr_t skater_motion = 0x4776a80;
|
||||
inline constexpr std::array<unsigned char, 32> skater_motion_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x50,0x49,
|
||||
0x8d,0x70,0x10,0x48,0x8b,0xd9,0x49,0x8d,0x78,0x20,0xc4,0xc1,0x7c,0x10,0x00,0xc5};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::debug_settings_access {
|
||||
// Expected first bytes of the settings manager lookup (engine::settings_lookup).
|
||||
inline constexpr std::array<unsigned char, 24> settings_lookup_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x59,0x70,0x48,0x8b,0xf9,0x48,0x8b};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Engine addresses shared by several features. Addresses only one feature uses
|
||||
// belong in that feature's own header.
|
||||
namespace dingosdk::game::build::v20260929::engine {
|
||||
|
||||
// Functions
|
||||
// UI control destructor shared by the cosmetic and music list vtables.
|
||||
inline constexpr std::uintptr_t ui_control_destroy = 0x214930;
|
||||
// Audio manager vtable slot +0x20.
|
||||
inline constexpr std::uintptr_t audio_manager_update = 0x43c2f0;
|
||||
// Entitlement check request.
|
||||
inline constexpr std::uintptr_t check_entitlements = 0x774340;
|
||||
// Constructs an engine error value.
|
||||
inline constexpr std::uintptr_t construct_error = 0x83de30;
|
||||
// Animation definition count getter (definition+0x1a0 -> +0xc).
|
||||
inline constexpr std::uintptr_t animation_count_getter = 0x11e5040;
|
||||
// Releases an engine-owned text handle.
|
||||
inline constexpr std::uintptr_t native_text_release = 0x1586050;
|
||||
inline constexpr std::uintptr_t string_assign = 0x1587620;
|
||||
// Returns the calling thread's game context.
|
||||
inline constexpr std::uintptr_t current_context = 0x18b6ae0;
|
||||
// Settings manager lookup by descriptor.
|
||||
inline constexpr std::uintptr_t settings_lookup = 0x18d6ef0;
|
||||
// UI data model lookup.
|
||||
inline constexpr std::uintptr_t model_find = 0x19193b0;
|
||||
// Game query context constructor.
|
||||
inline constexpr std::uintptr_t query_context_constructor = 0x276f3e0;
|
||||
// Skateboard render pose publisher.
|
||||
inline constexpr std::uintptr_t board_render_pose = 0x2e48610;
|
||||
// Assigns a future from a promise state.
|
||||
inline constexpr std::uintptr_t assign_future = 0x3dc5bd0;
|
||||
// Engine array allocation.
|
||||
inline constexpr std::uintptr_t array_allocate = 0x3dc7540;
|
||||
inline constexpr std::uintptr_t delegate_copy = 0x3dd5540;
|
||||
inline constexpr std::uintptr_t delegate_destroy = 0x3dd5780;
|
||||
// Sets a flag on a customization/indicator property.
|
||||
inline constexpr std::uintptr_t set_customization_flag = 0x4654e10;
|
||||
// Table functions graphs bind PostEventDelayedFrames (0x9967fc07) and
|
||||
// PostEventDelayedSeconds (0x5e3aee8e) calls to.
|
||||
inline constexpr std::uintptr_t post_event_delayed_frames = 0x4664e80;
|
||||
inline constexpr std::uintptr_t post_event_delayed_seconds = 0x4665040;
|
||||
// Completes a promise with a result or error.
|
||||
inline constexpr std::uintptr_t complete_promise = 0x58b1cf0;
|
||||
inline constexpr std::uintptr_t delegate_invoke = 0x58b1d60;
|
||||
// Player card info constructor.
|
||||
inline constexpr std::uintptr_t card_info_construct = 0x59dde00;
|
||||
|
||||
// Vtables
|
||||
// Connection controller.
|
||||
inline constexpr std::uintptr_t controller_vtable = 0x606dc60;
|
||||
inline constexpr std::uintptr_t allocator_adapter_vtable = 0x606df70;
|
||||
inline constexpr std::uintptr_t source_manager_vtable = 0x6079940;
|
||||
inline constexpr std::uintptr_t skater_audio_component_vtable = 0x607a500;
|
||||
inline constexpr std::uintptr_t audio_manager_vtable = 0x607b520;
|
||||
// Client game context; state at +0xc4.
|
||||
inline constexpr std::uintptr_t client_vtable = 0x60842c0;
|
||||
inline constexpr std::uintptr_t client_skater_source_vtable = 0x6084fa8;
|
||||
inline constexpr std::uintptr_t skater_entity_vtable = 0x6087908;
|
||||
inline constexpr std::uintptr_t cosmetics_manager_vtable = 0x6089cc8;
|
||||
// Current game server.
|
||||
inline constexpr std::uintptr_t server_vtable = 0x60e0890;
|
||||
// DelMarGameSettings.
|
||||
inline constexpr std::uintptr_t game_settings_vtable = 0x6145be0;
|
||||
inline constexpr std::uintptr_t skater_component_vtable = 0x6170410;
|
||||
inline constexpr std::uintptr_t board_component_vtable = 0x6170e38;
|
||||
inline constexpr std::uintptr_t board_entity_vtable = 0x6171808;
|
||||
inline constexpr std::uintptr_t board_data_vtable = 0x6186fa0;
|
||||
// Blueprint objects, including the board and UI widget blueprints.
|
||||
inline constexpr std::uintptr_t blueprint_vtable = 0x6187008;
|
||||
// Skater appearance entity.
|
||||
inline constexpr std::uintptr_t skater_appearance_vtable = 0x61c9ec0;
|
||||
inline constexpr std::uintptr_t camera_vtable = 0x6255870;
|
||||
inline constexpr std::uintptr_t board_holder_vtable = 0x63db690;
|
||||
inline constexpr std::uintptr_t local_player_vtable = 0x63e2d30;
|
||||
inline constexpr std::uintptr_t local_player_manager_vtable = 0x63e30a0;
|
||||
inline constexpr std::uintptr_t server_player_vtable = 0x63e4108;
|
||||
inline constexpr std::uintptr_t server_player_manager_vtable = 0x63e41d0;
|
||||
|
||||
// Type information
|
||||
inline constexpr std::uintptr_t cosmetic_slot_type = 0x72290b8;
|
||||
inline constexpr std::uintptr_t persona_id_type = 0x723bcf8;
|
||||
inline constexpr std::uintptr_t player_info_type = 0x72591d8;
|
||||
inline constexpr std::uintptr_t board_blueprint_type = 0x7288f80;
|
||||
inline constexpr std::uintptr_t reference_type = 0x7493030;
|
||||
inline constexpr std::uintptr_t bool_type = 0x765d8e8;
|
||||
inline constexpr std::uintptr_t uint32_type = 0x765da28;
|
||||
inline constexpr std::uintptr_t int32_type = 0x765da68;
|
||||
inline constexpr std::uintptr_t uint64_type = 0x765daa8;
|
||||
inline constexpr std::uintptr_t int64_type = 0x765dae8;
|
||||
inline constexpr std::uintptr_t float_type = 0x765db28;
|
||||
// CString.
|
||||
inline constexpr std::uintptr_t string_type = 0x765dcc0;
|
||||
|
||||
// Globals
|
||||
// uint32 offset of the partition record in a game context.
|
||||
inline constexpr std::uintptr_t context_partition_offset = 0x6fc2430;
|
||||
// uint32 offset of the type/realm record in a game context.
|
||||
inline constexpr std::uintptr_t context_type_offset = 0x6fc24b8;
|
||||
// uint32 offset of the client connection in a game context.
|
||||
inline constexpr std::uintptr_t context_client_connection_offset = 0x715d5e8;
|
||||
// uint32 offset of the player manager in a game context.
|
||||
inline constexpr std::uintptr_t context_player_manager_offset = 0x7161720;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t backend_services = 0x71e17e0;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t audio_manager = 0x71e3978;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t appearance_manager = 0x71eaba0;
|
||||
// Pointer array indexed by partition * 7 + kind.
|
||||
inline constexpr std::uintptr_t source_managers = 0x71ec560;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t loadout_manager = 0x71ec8c8;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t cosmetics_manager = 0x71ee610;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t client_game_manager = 0x71ef898;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t location_owner = 0x71f4288;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t ui_manager = 0x71f5ef0;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t game_server = 0x71f8ad0;
|
||||
// DelMarGameSettings handle.
|
||||
inline constexpr std::uintptr_t game_settings_handle = 0x725b018;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t default_arena = 0x72f6e78;
|
||||
// The engine's empty CString.
|
||||
inline constexpr std::uintptr_t empty_cstring = 0x72fe704;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t settings_manager = 0x7482ce0;
|
||||
// Pointer.
|
||||
inline constexpr std::uintptr_t ui_allocator = 0x74889e8;
|
||||
// uint8.
|
||||
inline constexpr std::uintptr_t entity_creation_ready = 0x75c26f0;
|
||||
// uint32 registration offset between player base classes.
|
||||
inline constexpr std::uintptr_t player_parent_offset = 0x75c44d8;
|
||||
// Pointer array indexed by domain.
|
||||
inline constexpr std::uintptr_t domain_owners = 0x7726d10;
|
||||
// uint32 engine TLS slot.
|
||||
inline constexpr std::uintptr_t tls_index = 0x78d40e0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <cstdint>
|
||||
|
||||
// Engine work the developers left on the table (Extension/Rendering/engine_tweaks.cpp, `perf`
|
||||
// toggles; analysis/sim-performance.md "Engine code").
|
||||
namespace dingosdk::game::build::v20260929::engine_tweaks {
|
||||
// 1. Enlighten GI's per-frame update(manager, mode): runs on the render thread every rendered
|
||||
// frame and dispatches the probe-set solves (SolveEntireProbeSetTask 0x54d5090, SolveIrradianceTask
|
||||
// 0x54c7350). Its own skip path (manager + 0x18 bits 0/1) returns at once with nothing done.
|
||||
// The GI frame (0x5196270) calls it once a frame (gi_update_frame_return), or, while a setting
|
||||
// (+0x13d of the object its +0x250 getter returns) is on, settings + 0x78 times in a row
|
||||
// (gi_update_loop_return).
|
||||
inline constexpr Fingerprint gi_update{0x518c4c0, {
|
||||
0x40,0x55,0x41,0x55,0x41,0x56,0x48,0x8d,0xac,0x24,0x90,0xf6,0xff,0xff,0x48,0x81,
|
||||
0xec,0x70,0x0a,0x00,0x00,0x48,0x8b,0x05,0xe4,0x7e,0x03,0x02,0x48,0x33,0xc4,0x48}};
|
||||
inline constexpr std::uintptr_t gi_update_frame_return = 0x51966f4;
|
||||
inline constexpr std::uintptr_t gi_update_loop_return = 0x519660c;
|
||||
|
||||
// 2. meshCull's scheduler, run once per frame before the cull jobs; 11 arguments (rcx, rdx, r9
|
||||
// and stack slots 5, 6 and 11 are read; no floating-point ones). It and the job (0x4eecaa0)
|
||||
// each read WorldRenderSettings.MeshCullTreeEnabled (+0x95d): 0 = flat path testing every chunk
|
||||
// against every view, 1 = octree pre-cull (meshCullPrepare 0x4eb2b70). The settings come from
|
||||
// settings_lookup(*settings_manager, world_render_settings_type).
|
||||
inline constexpr Fingerprint mesh_cull_schedule{0x4eb6620, {
|
||||
0x48,0x89,0x5c,0x24,0x18,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,
|
||||
0x48,0x8d,0xac,0x24,0xe0,0xfd,0xff,0xff,0x48,0x81,0xec,0x20,0x03,0x00,0x00,0x48}};
|
||||
inline constexpr std::uintptr_t settings_lookup = 0x18d6ef0;
|
||||
inline constexpr std::uintptr_t settings_manager = 0x7482ce0;
|
||||
inline constexpr std::uintptr_t world_render_settings_type = 0x78adf88;
|
||||
inline constexpr std::uintptr_t mesh_cull_tree_enabled = 0x95d;
|
||||
|
||||
// 3. Job wake-ups. push(scheduler, job): if scheduler + 0x271 runs the job at once (execute
|
||||
// 0x321ed00 on scheduler + 0x2c0); else pushes it (lockfree_push(scheduler + 0x10, job, job + 0x40))
|
||||
// and wakes the first worker, in bit order, of the waiter mask (scheduler + 0x2a8) that wake()
|
||||
// accepts. Worker states (worker_table + i * worker_stride + worker_state): 3 running, 4 entering
|
||||
// idle (wake = one CAS 4 -> 3), 5 asleep (wake = CAS 5 -> 3 and a semaphore release). The idle
|
||||
// wait (0x3228220) sets 3 -> 4, calls register_idle (no arguments), which puts the worker's bit in
|
||||
// its schedulers' and queues' masks and returns non-zero if work was already there; on 0 it sets
|
||||
// 4 -> 5 and sleeps, or, when a pusher got there first (state 3), unregisters and runs again.
|
||||
// The worker is read from the thread's TLS block (gs:[0x58][*tls_index] + tls_worker).
|
||||
inline constexpr Fingerprint job_push{0x321d710, {
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x80,0xb9,0x71,0x02,0x00,0x00,0x00,0x48,0x8b,0xd9,
|
||||
0x75,0x6a,0x4c,0x8d,0x42,0x40,0x48,0x89,0x74,0x24,0x38,0x48,0x83,0xc1,0x10,0xe8}};
|
||||
inline constexpr Fingerprint job_register_idle{0x3227290, {
|
||||
0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x8b,0x0d,0x41,0xce,0x6a,0x04,0x65,
|
||||
0x48,0x8b,0x04,0x25,0x58,0x00,0x00,0x00,0xbd,0x19,0x0b,0x00,0x00,0x48,0x8b,0x1c}};
|
||||
inline constexpr std::uintptr_t job_wake = 0x3228400;
|
||||
inline constexpr std::uintptr_t job_lockfree_push = 0x11eb850;
|
||||
inline constexpr std::uintptr_t job_execute = 0x321ed00;
|
||||
inline constexpr std::uintptr_t job_scheduler_immediate = 0x271;
|
||||
inline constexpr std::uintptr_t job_scheduler_queue = 0x10;
|
||||
inline constexpr std::uintptr_t job_scheduler_inline = 0x2c0;
|
||||
inline constexpr std::uintptr_t job_scheduler_waiters = 0x2a8;
|
||||
inline constexpr std::uintptr_t job_link = 0x40;
|
||||
inline constexpr std::uintptr_t worker_table = 0x7607770;
|
||||
inline constexpr std::uintptr_t worker_stride = 0x178;
|
||||
inline constexpr std::uintptr_t worker_state = 0x5c;
|
||||
inline constexpr std::uintptr_t tls_index = 0x78d40e0;
|
||||
inline constexpr std::uintptr_t tls_worker = 0x6c8;
|
||||
|
||||
// 4. Precise sleep(timer, nanoseconds): an auto-reset, non-high-resolution waitable timer (timer +
|
||||
// 8, made by 0x18b0220) for all but the last 1 ms (0x73c7ba0, from timeGetDevCaps), then a spin,
|
||||
// at thread priority 15 while it waits; clears and obeys an abort flag (timer + 0x10) on every
|
||||
// pass. The abort (0x18bd9d0) sets the flag and fires the timer at once. The main loop (0x4645080)
|
||||
// calls it with what is left of its 10 ms budget; main_sleep_return is that call's return address.
|
||||
inline constexpr Fingerprint precise_sleep{0x18bcc30, {
|
||||
0x40,0x55,0x53,0x56,0x57,0x41,0x56,0x48,0x8d,0x6c,0x24,0xc9,0x48,0x81,0xec,0xa0,
|
||||
0x00,0x00,0x00,0x4c,0x8b,0x35,0x26,0xb1,0xb0,0x05,0x48,0x8b,0xda,0x48,0x8b,0xf1}};
|
||||
inline constexpr std::uintptr_t main_sleep_return = 0x4645328;
|
||||
inline constexpr std::uintptr_t sleep_timer_handle = 0x8;
|
||||
inline constexpr std::uintptr_t sleep_abort = 0x10;
|
||||
|
||||
// 5. Expression shader section dirty-bit clear(section), every frame for every section (only
|
||||
// caller 0x505a9a0): takes a byte spinlock (+0x16) to clear the dirty bits -- bits 0-17 of the
|
||||
// dword at +0x18 when the list at +0x128..+0x130 is empty, else bits 0-5 of the word at +0x1c --
|
||||
// and only does more (sets bit 18, notifies) when some were set. With none set it writes back
|
||||
// the same values: no effect but the lock.
|
||||
inline constexpr Fingerprint section_dirty_clear{0x506d620, {
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x81,0x30,0x01,0x00,0x00,0x48,0x8b,0xd9,
|
||||
0x48,0x39,0x81,0x28,0x01,0x00,0x00,0xb9,0x01,0x00,0x00,0x00,0x74,0x32,0x66,0x90}};
|
||||
inline constexpr std::uintptr_t section_list_begin = 0x128;
|
||||
inline constexpr std::uintptr_t section_list_end = 0x130;
|
||||
inline constexpr std::uintptr_t section_dirty = 0x18; // dword
|
||||
inline constexpr std::uint32_t section_dirty_mask = 0x3ffff;
|
||||
inline constexpr std::uintptr_t section_list_dirty = 0x1c; // word
|
||||
inline constexpr std::uint16_t section_list_dirty_mask = 0x3f;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include "engine.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::entitlement_trace {
|
||||
// CheckUsersEntitlements entry.
|
||||
inline constexpr std::uintptr_t check_entitlements = engine::check_entitlements;
|
||||
inline constexpr std::array<unsigned char, 40> check_entitlements_prefix{
|
||||
0x40,0x55,0x57,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xd8,0x48,0x81,0xec,0x28,
|
||||
0x01,0x00,0x00,0x48,0x8b,0x05,0x66,0x00,0xa5,0x06,0x48,0x33,0xc4,0x48,0x89,0x45,
|
||||
0x08,0x4c,0x8b,0xf1,0x4c,0x89,0x44,0x24};
|
||||
// Progression request builder.
|
||||
inline constexpr std::uintptr_t request_builder = 0x3912d0;
|
||||
inline constexpr std::array<unsigned char, 40> request_builder_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x20,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,
|
||||
0x48,0x8d,0xac,0x24,0x40,0xff,0xff,0xff,0x48,0x81,0xec,0xc0,0x01,0x00,0x00,0x48,
|
||||
0x8b,0x05,0xca,0x30,0xe3,0x06,0x48,0x33};
|
||||
// Entitlement result reducer.
|
||||
inline constexpr std::uintptr_t result_reducer = 0x76d900;
|
||||
inline constexpr std::array<unsigned char, 40> result_reducer_prefix{
|
||||
0x48,0x8b,0xc4,0x53,0x57,0x48,0x81,0xec,0xa8,0x00,0x00,0x00,0x4c,0x89,0x60,0xe0,
|
||||
0x48,0x8b,0xda,0x4c,0x89,0x70,0xd0,0x4c,0x8b,0xe1,0x48,0x89,0x4c,0x24,0x78,0x48,
|
||||
0x8d,0x4c,0x24,0x28,0x4c,0x89,0x78,0xc8};
|
||||
// Entitlement request continuation callback.
|
||||
inline constexpr std::uintptr_t continuation_callback = 0x76f7f0;
|
||||
inline constexpr std::array<unsigned char, 40> continuation_callback_prefix{
|
||||
0x4c,0x8b,0xdc,0x49,0x89,0x5b,0x20,0x57,0x48,0x83,0xec,0x60,0x48,0x8b,0x02,0x4d,
|
||||
0x8b,0xc8,0x49,0x89,0x43,0xb8,0x4d,0x8d,0x43,0xb8,0x48,0x8b,0x42,0x08,0x48,0x8b,
|
||||
0xfa,0x49,0x89,0x43,0xc0,0x48,0x8b,0xd9};
|
||||
// Promise capture inside check_entitlements.
|
||||
inline constexpr std::uintptr_t entry_promise_capture = 0x774665;
|
||||
inline constexpr std::array<unsigned char, 48> entry_promise_capture_prefix{
|
||||
0x48,0x8b,0x54,0x24,0x58,0x48,0x8d,0x4c,0x24,0x38,0xe8,0xcc,0x0e,0x66,0x03,0x48,
|
||||
0x8d,0x05,0x25,0xb3,0xff,0xff,0x48,0x89,0x45,0xf0,0x48,0x8d,0x54,0x24,0x38,0x48,
|
||||
0x8d,0x05,0x65,0xb1,0xff,0xff,0x48,0x8d,0x4d,0xe0,0x48,0x89,0x45,0xf8,0xe8,0xa8};
|
||||
// check_entitlements' call to the request builder and its return address.
|
||||
inline constexpr std::uintptr_t entry_builder_call = 0x7746fe;
|
||||
inline constexpr std::uintptr_t entry_builder_return = 0x77470f;
|
||||
inline constexpr std::array<unsigned char, 17> entry_builder_call_prefix{
|
||||
0x4c,0x8d,0x45,0xa0,0x49,0x8b,0xcd,0x48,0x8d,0x54,0x24,0x60,0xe8,0xc1,0xcb,0xc1,
|
||||
0xff};
|
||||
// The continuation callback's call to the result reducer and its return address.
|
||||
inline constexpr std::uintptr_t callback_reducer_call = 0x76f846;
|
||||
inline constexpr std::uintptr_t callback_reducer_return = 0x76f858;
|
||||
inline constexpr std::array<unsigned char, 18> callback_reducer_call_prefix{
|
||||
0x49,0x8b,0xc9,0xc5,0xf8,0x11,0x44,0x24,0x40,0x89,0x44,0x24,0x50,0xe8,0xa8,0xe0,
|
||||
0xff,0xff};
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::entity_pages {
|
||||
// EntityBus TransformSpace and property-space snapshots have 128 page pointers.
|
||||
// Slot 131072 formerly indexed pointer 128, overwriting snapshot bookkeeping.
|
||||
// Enlarge the leaf pages without changing owners, snapshots, IDs or their ABI.
|
||||
inline constexpr std::size_t page_count = 128;
|
||||
inline constexpr unsigned page_shift = 13;
|
||||
inline constexpr std::size_t page_entries = std::size_t{1} << page_shift;
|
||||
inline constexpr std::size_t page_bytes = page_entries * sizeof(std::uint64_t);
|
||||
inline constexpr std::size_t capacity = page_count * page_entries;
|
||||
// The native render-instance pool keeps 512 page pointers and stable direct
|
||||
// object addresses. Grow its 464-byte-record pages without changing that ABI.
|
||||
inline constexpr unsigned render_instance_page_shift = 11;
|
||||
inline constexpr std::size_t render_instance_page_bytes = (std::size_t{1} << render_instance_page_shift) * 0x1d0;
|
||||
static_assert((512u << render_instance_page_shift) == capacity);
|
||||
static_assert(render_instance_page_bytes == 0xe8000);
|
||||
// The cull-ID list and this separate monotonic bounds arena must both fit the
|
||||
// city. Retail config can override GridSize after Game.cfg has been processed.
|
||||
// Preserve native allocation/reset ownership and any larger configured value.
|
||||
inline constexpr std::size_t spatial_arena_min_bytes = 128 * 1024 * 1024;
|
||||
static_assert(capacity == 1'048'576 && page_bytes == 65'536);
|
||||
|
||||
enum class Kind {
|
||||
index_shift, page_bytes, page_entries, spatial_arena_bytes,
|
||||
spatial_bucket_layout, spatial_bucket_stride, pending_update_pages,
|
||||
render_instance_pages
|
||||
};
|
||||
struct Patch {
|
||||
std::uintptr_t rva;
|
||||
Kind kind;
|
||||
std::size_t size;
|
||||
std::array<std::uint8_t, 15> expected{};
|
||||
std::array<std::uint8_t, 15> replacement{};
|
||||
};
|
||||
|
||||
template<std::size_t N>
|
||||
consteval Patch patch(std::uintptr_t rva, Kind kind,
|
||||
const char (&before)[N], const char (&after)[N]) {
|
||||
static_assert(N % 2 == 1 && N > 1 && N <= 31);
|
||||
Patch result{rva, kind, (N - 1) / 2};
|
||||
const auto nibble = [](char c) -> unsigned {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
throw "Invalid entity-page instruction contract";
|
||||
};
|
||||
for (std::size_t i = 0; i < result.size; ++i) {
|
||||
result.expected[i] = static_cast<std::uint8_t>((nibble(before[i*2]) << 4) | nibble(before[i*2+1]));
|
||||
result.replacement[i] = static_cast<std::uint8_t>((nibble(after[i*2]) << 4) | nibble(after[i*2+1]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Reviewed complete instructions for the supported executable. In addition to
|
||||
// insertion/lookup, include iteration skips, copy-on-write and snapshot repair.
|
||||
// The separate 24-byte update queues are covered by pending_updates.h.
|
||||
inline constexpr Patch patches[]{
|
||||
// Sized destruction, insertion, allocation and the losing CAS allocation.
|
||||
// The native page-index assertion remains enabled.
|
||||
patch(0x050cdf6c, Kind::render_instance_pages, "41b800a00300", "41b800800e00"),
|
||||
patch(0x050e23f6, Kind::render_instance_pages, "c1eb09", "c1eb0b"),
|
||||
patch(0x050e2422, Kind::render_instance_pages, "ba00a00300", "ba00800e00"),
|
||||
patch(0x050e244d, Kind::render_instance_pages, "41b800a00300", "41b800800e00"),
|
||||
patch(0x050e245b, Kind::render_instance_pages, "c1e309", "c1e30b"),
|
||||
patch(0x0489ee29, Kind::spatial_arena_bytes, "bb00001000", "bb00000008"),
|
||||
patch(0x048a5e2c, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048a5e31, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048a5fec, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048a6015, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048b2713, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048b271c, Kind::index_shift, "41c1e00a", "41c1e00d"),
|
||||
patch(0x048b27b6, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b27c4, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b292a, Kind::index_shift, "41c1ea0a", "41c1ea0d"),
|
||||
patch(0x048b2935, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b3816, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048b3823, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048b5112, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x048b5122, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x048b51a2, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x048b51b2, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x048b52d6, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b52dd, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b53cc, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b53d3, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b5692, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x048b56a2, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x048b57a9, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b57b0, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b58b6, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b58bd, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048b7ed4, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048b7ef5, Kind::index_shift, "41c1e00a", "41c1e00d"),
|
||||
patch(0x048b8530, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048b8556, Kind::index_shift, "41c1e20a", "41c1e20d"),
|
||||
patch(0x048b9879, Kind::page_entries, "81c600040000", "81c600200000"),
|
||||
patch(0x048b9881, Kind::page_bytes, "488daf00200000", "488daf00000100"),
|
||||
patch(0x048b99d8, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x048b99e4, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x048bb417, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048bb41f, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048bb76a, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bb771, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bb83a, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bb841, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bb8fa, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bb901, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bb9b9, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bb9c0, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bba89, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bba90, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bbb89, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bbb90, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bbc4b, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bbc52, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bbd39, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bbd40, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bbf26, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bbf2d, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bd05e, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x048bd06d, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048bd42b, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bd432, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048bd516, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048bd51d, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048c019e, Kind::page_entries, "4181c600040000", "4181c600200000"),
|
||||
patch(0x048c01a7, Kind::page_bytes, "4c8da500200000", "4c8da500000100"),
|
||||
patch(0x048c024e, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x048c025a, Kind::page_bytes, "4c8dad00200000", "4c8dad00000100"),
|
||||
patch(0x048c069c, Kind::page_entries, "81c500040000", "81c500200000"),
|
||||
patch(0x048c06a4, Kind::page_bytes, "4c8db600200000", "4c8db600000100"),
|
||||
patch(0x048c07f8, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x048c0804, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x048c3942, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x048c394f, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c39bb, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048c39c0, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048c4fbd, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048c4fc5, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048c50cf, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x048c50d6, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x048c5d01, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x048c5d06, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x048c5e1c, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5e53, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5e8d, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5ec7, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5f01, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5f3b, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5f75, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c5faf, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x048c703c, Kind::index_shift, "c1ea0a", "c1ea0d"),
|
||||
patch(0x048c7049, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ea3670, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ea3692, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ea3b1d, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ea3b42, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04ea3bd0, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ea3bf8, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04ec12e3, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ec12eb, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ec13c7, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ec13d5, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04ec2b13, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ec2b1b, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ec2bf7, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ec2c05, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04ec2dc6, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ec2dce, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ec2eb7, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ec2ec5, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04ed56e9, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ed56ef, Kind::index_shift, "41c1e20a", "41c1e20d"),
|
||||
patch(0x04ed7296, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ed72bb, Kind::index_shift, "41c1e20a", "41c1e20d"),
|
||||
patch(0x04ed78d3, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ed78f8, Kind::index_shift, "41c1e20a", "41c1e20d"),
|
||||
patch(0x04edde6d, Kind::page_entries, "81c500040000", "81c500200000"),
|
||||
patch(0x04edde78, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00000100"),
|
||||
patch(0x04eddff8, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x04ede004, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x04edf33d, Kind::page_entries, "81c500040000", "81c500200000"),
|
||||
patch(0x04edf348, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00000100"),
|
||||
patch(0x04edf508, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x04edf514, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x04edf719, Kind::page_entries, "81c600040000", "81c600200000"),
|
||||
patch(0x04edf724, Kind::page_bytes, "488daf00200000", "488daf00000100"),
|
||||
patch(0x04edf888, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x04edf894, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x04ee3b47, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ee3b4f, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ee4c77, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ee4c7f, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ee4f47, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ee4f4f, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04ee7daa, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04ee7db9, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04eeb19e, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x04eeb1ad, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04eeba26, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eeba2d, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eec74e, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eec755, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eec8ac, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eec8b3, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eecd45, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eecd58, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eece4d, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04eece6e, Kind::index_shift, "41c1e00a", "41c1e00d"),
|
||||
patch(0x04eecf6d, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eecf74, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed0a0, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed0a7, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed221, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed228, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed31c, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed323, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed468, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04eed474, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed6a0, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed6a7, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed869, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed870, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eed9f4, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eed9fb, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eee208, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee21b, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee292, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee2a5, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee328, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee33b, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee3b2, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee3c5, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee432, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee445, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee4a2, Kind::index_shift, "41c1e80a", "41c1e80d"),
|
||||
patch(0x04eee4b5, Kind::index_shift, "c1e00a", "c1e00d"),
|
||||
patch(0x04eee60a, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04eee62b, Kind::index_shift, "41c1e00a", "41c1e00d"),
|
||||
patch(0x04eee72f, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eee736, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eee89f, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eee8a6, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eeea0a, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eeea11, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eeecbe, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eeecc5, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eeede9, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eeedf0, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eef06f, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eef076, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eef15d, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eef164, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04eef214, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04eef21b, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04ef0113, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04ef011a, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04ef5deb, Kind::page_entries, "4181c700040000", "4181c700200000"),
|
||||
patch(0x04ef5df7, Kind::page_bytes, "498db600200000", "498db600000100"),
|
||||
patch(0x04ef5f89, Kind::page_entries, "4181c700040000", "4181c700200000"),
|
||||
patch(0x04ef5f95, Kind::page_bytes, "498db600200000", "498db600000100"),
|
||||
patch(0x04ef60ae, Kind::page_entries, "4181c500040000", "4181c500200000"),
|
||||
patch(0x04ef60ba, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00000100"),
|
||||
patch(0x04efc57b, Kind::page_entries, "4181c700040000", "4181c700200000"),
|
||||
patch(0x04efc587, Kind::page_bytes, "498db600200000", "498db600000100"),
|
||||
patch(0x04efc729, Kind::page_entries, "4181c700040000", "4181c700200000"),
|
||||
patch(0x04efc735, Kind::page_bytes, "498db600200000", "498db600000100"),
|
||||
patch(0x04efc858, Kind::page_entries, "4181c500040000", "4181c500200000"),
|
||||
patch(0x04efc864, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00000100"),
|
||||
patch(0x04f097dc, Kind::page_entries, "4181c600040000", "4181c600200000"),
|
||||
patch(0x04f097e5, Kind::page_bytes, "4c8da500200000", "4c8da500000100"),
|
||||
patch(0x04f0988f, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x04f0989b, Kind::page_bytes, "4c8dad00200000", "4c8dad00000100"),
|
||||
patch(0x04f0b69c, Kind::page_entries, "81c500040000", "81c500200000"),
|
||||
patch(0x04f0b6a7, Kind::page_bytes, "4c8db600200000", "4c8db600000100"),
|
||||
patch(0x04f0b828, Kind::page_entries, "4181c400040000", "4181c400200000"),
|
||||
patch(0x04f0b834, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
|
||||
patch(0x04f16e2d, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f16e34, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f16ee4, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f16eeb, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f1700c, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f17013, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f172d4, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f172db, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f17618, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f1761f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f19462, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x04f1946f, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f194db, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f194e0, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f19ca2, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x04f19caf, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f19d1b, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f19d20, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f19f22, Kind::page_bytes, "b900200000", "b900000100"),
|
||||
patch(0x04f19f2f, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f19f9b, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f19fa0, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f1e856, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f1e863, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f1e8f0, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f1e8f8, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f1ea31, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f1ea39, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f216fd, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f2170a, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f21796, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f2179e, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f218d6, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f218de, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f21f09, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f21f16, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f21fa2, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f21faa, Kind::index_shift, "c1e20a", "c1e20d"),
|
||||
patch(0x04f220e8, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f220f0, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2b166, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f2b16b, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2b2e4, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f2b2ec, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2b38c, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b3c3, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b3fd, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b437, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b471, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b4ab, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b4e5, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2b51f, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2e6e6, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f2e6eb, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2e877, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f2e87f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2e92c, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2e963, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2e99d, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2e9d7, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ea11, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ea4b, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ea85, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2eabf, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ec86, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f2ec8b, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2ee27, Kind::index_shift, "c1e90a", "c1e90d"),
|
||||
patch(0x04f2ee2f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f2eedc, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ef13, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ef4d, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2ef87, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2efc1, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2effb, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2f035, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f2f06f, Kind::page_bytes, "41b800200000", "41b800000100"),
|
||||
patch(0x04f34fea, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f34ff1, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f35b60, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f35b66, Kind::index_shift, "41c1e00a", "41c1e00d"),
|
||||
patch(0x04f38418, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f3841f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f38958, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f3895f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
patch(0x04f38a78, Kind::index_shift, "c1e80a", "c1e80d"),
|
||||
patch(0x04f38a7f, Kind::index_shift, "c1e10a", "c1e10d"),
|
||||
};
|
||||
} // namespace dingosdk::game::build::v20260929::entity_pages
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
#include "engine.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Fixed-stop entitlement provider (CheckEntitlements replacement).
|
||||
namespace dingosdk::game::build::v20260929::fixed_stop_entitlement {
|
||||
// CheckEntitlements entry.
|
||||
inline constexpr std::uintptr_t check_entitlements = engine::check_entitlements;
|
||||
inline constexpr std::array<unsigned char, 40> check_entitlements_prefix{
|
||||
0x40,0x55,0x57,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xd8,0x48,0x81,0xec,0x28,
|
||||
0x01,0x00,0x00,0x48,0x8b,0x05,0x66,0x00,0xa5,0x06,0x48,0x33,0xc4,0x48,0x89,0x45,
|
||||
0x08,0x4c,0x8b,0xf1,0x4c,0x89,0x44,0x24};
|
||||
// Group enumeration inside CheckEntitlements: the exact entry sequence that
|
||||
// reserves the eID list, reads group +0x88/+0x90, skips null members, obtains
|
||||
// the native eID key, and calls the member lookup.
|
||||
inline constexpr std::uintptr_t group_enumeration = 0x77458f;
|
||||
inline constexpr std::array<unsigned char, 76> group_enumeration_prefix{
|
||||
0x48,0x8d,0x4c,0x24,0x60,0x48,0x8b,0x93,0x90,0x00,0x00,0x00,0x48,0x2b,0x93,0x88,
|
||||
0x00,0x00,0x00,0x48,0xc1,0xfa,0x04,0xe8,0xd5,0x62,0xae,0xff,0x4c,0x8b,0xbb,0x90,
|
||||
0x00,0x00,0x00,0x48,0x8b,0xb3,0x88,0x00,0x00,0x00,0x49,0x3b,0xf7,0x74,0x7c,0x66,
|
||||
0x90,0x48,0x8b,0x5e,0x08,0x48,0x85,0xdb,0x74,0x68,0x33,0xc9,0xe8,0x70,0x50,0xfd,
|
||||
0xff,0x48,0x8b,0xd0,0x48,0x8b,0xcb,0xe8,0xf5,0x05,0xf5,0x00};
|
||||
// Result vector constructor.
|
||||
inline constexpr std::uintptr_t result_vector_init = 0x58b1cb0;
|
||||
inline constexpr std::array<unsigned char, 37> result_vector_init_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0xd9,0xe8,0x42,0x9e,0x51,0xfe,0x48,0x89,
|
||||
0x03,0x48,0x8b,0xc3,0xc7,0x43,0x08,0x00,0x00,0x00,0x00,0xc6,0x43,0x0c,0x00,0x48,
|
||||
0x83,0xc4,0x20,0x5b,0xc3};
|
||||
// Native array allocation.
|
||||
inline constexpr std::uintptr_t array_allocate = engine::array_allocate;
|
||||
inline constexpr std::array<unsigned char, 61> array_allocate_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x49,0x8b,0xc1,0x49,0x8b,0xd8,
|
||||
0x48,0x8b,0xfa,0x4d,0x85,0xc9,0x75,0x07,0xb2,0x01,0xe8,0xe1,0x26,0x7d,0xfd,0x44,
|
||||
0x0f,0xb6,0x4c,0x24,0x48,0x4c,0x8b,0xc3,0x48,0x8b,0xd7,0x48,0x8b,0xc8,0x48,0x8b,
|
||||
0x5c,0x24,0x30,0x48,0x83,0xc4,0x20,0x5f,0xe9,0xc3,0x7a,0x7b,0xfd};
|
||||
// Result element constructor.
|
||||
inline constexpr std::uintptr_t result_element_init = 0x58b1c70;
|
||||
inline constexpr std::array<unsigned char, 50> result_element_init_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0xd9,0xe8,0x82,0x9e,0x51,0xfe,0x48,0x89,
|
||||
0x03,0x48,0x8d,0x05,0x7c,0xca,0xa4,0x01,0x48,0x89,0x43,0x08,0xe8,0x6f,0x9e,0x51,
|
||||
0xfe,0x48,0x89,0x43,0x10,0x48,0x8b,0xc3,0xc6,0x43,0x18,0x00,0x48,0x83,0xc4,0x20,
|
||||
0x5b,0xc3};
|
||||
// Expected leading bytes of engine::string_assign.
|
||||
inline constexpr std::array<unsigned char, 40> assign_string_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x10,0x48,0x89,0x6c,0x24,0x18,0x56,0x57,0x41,0x56,0x48,0x83,
|
||||
0xec,0x20,0x48,0x8b,0x19,0x48,0x8d,0x2d,0xc8,0x70,0xd7,0x05,0x41,0x8b,0xf0,0x4c,
|
||||
0x8b,0xf2,0x48,0x8b,0xf9,0x48,0x85,0xdb};
|
||||
// Error constructor.
|
||||
inline constexpr std::uintptr_t construct_error = engine::construct_error;
|
||||
inline constexpr std::array<unsigned char, 40> construct_error_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,
|
||||
0x8b,0xf1,0x49,0x8b,0xd8,0x49,0x8b,0xc8,0x8b,0xfa,0xe8,0xcd,0xfb,0xc2,0x04,0x44,
|
||||
0x8b,0xc0,0x48,0x8b,0xd3,0x48,0x8b,0xce};
|
||||
// Promise completion.
|
||||
inline constexpr std::uintptr_t complete_promise = engine::complete_promise;
|
||||
inline constexpr std::array<unsigned char, 40> complete_promise_prefix{
|
||||
0x48,0x83,0xec,0x48,0x4c,0x8b,0xd2,0x48,0x8b,0x11,0x48,0x8b,0x02,0x4c,0x8b,0x48,
|
||||
0x40,0x4d,0x85,0xc9,0x74,0x14,0x80,0x78,0x68,0x00,0x75,0x0e,0x49,0x8b,0xd0,0x49,
|
||||
0x8b,0xca,0x41,0xff,0xd1,0x48,0x83,0xc4};
|
||||
// Result vector destructor.
|
||||
inline constexpr std::uintptr_t destroy_vector = 0x76c370;
|
||||
inline constexpr std::array<unsigned char, 40> destroy_vector_prefix{
|
||||
0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,
|
||||
0xf1,0xe8,0x7a,0xf7,0x65,0x03,0x48,0x3b,0xf0,0x74,0x79,0x49,0x8b,0x06,0x48,0x89,
|
||||
0x5c,0x24,0x30,0x48,0x8b,0xde,0x48,0x89};
|
||||
// Expected leading bytes of engine::native_text_release.
|
||||
inline constexpr std::array<unsigned char, 40> destroy_string_prefix{
|
||||
0x48,0x8b,0x09,0x48,0x8d,0x05,0xaa,0x86,0xd7,0x05,0x48,0x3b,0xc8,0x0f,0x85,0xbd,
|
||||
0x9b,0xff,0xff,0xc3,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83};
|
||||
// Future assignment.
|
||||
inline constexpr std::uintptr_t assign_future = engine::assign_future;
|
||||
inline constexpr std::array<unsigned char, 34> assign_future_prefix{
|
||||
0x48,0x85,0xd2,0x74,0x16,0xb8,0x00,0x20,0x00,0x00,0x66,0x85,0x42,0x14,0x48,0x8b,
|
||||
0xc1,0x75,0x0b,0xf0,0xff,0x42,0x10,0x48,0x89,0x11,0xc3,0x48,0x8b,0xc1,0x48,0x89,
|
||||
0x11,0xc3};
|
||||
// Native eID key getter.
|
||||
inline constexpr std::uintptr_t eid_key = 0x749640;
|
||||
inline constexpr std::array<unsigned char, 21> eid_key_prefix{
|
||||
0x48,0x8d,0x05,0x65,0xfd,0x98,0x05,0x48,0x89,0x44,0x24,0x10,0x48,0x63,0xc1,0x48,
|
||||
0x8b,0x44,0xc4,0x10,0xc3};
|
||||
// Group member eID lookup.
|
||||
inline constexpr std::uintptr_t find_member_eid = 0x16c4bd0;
|
||||
inline constexpr std::array<unsigned char, 40> find_member_eid_prefix{
|
||||
0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x48,0x48,0x8d,0x71,0x60,0xc6,0x44,0x24,
|
||||
0x20,0x00,0x48,0x8b,0xca,0xc6,0x44,0x24,0x2f,0x0f,0x48,0x8b,0xea,0x48,0xc7,0x44,
|
||||
0x24,0x30,0x00,0x00,0x00,0x00,0xe8,0x21};
|
||||
// Pointer to the client game manager.
|
||||
inline constexpr std::uintptr_t client_game_manager = engine::client_game_manager;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::gameplay_settings {
|
||||
// Dedicated fast-travel getter: independent evidence for the
|
||||
// FastTravelPointsEnabled byte, and its expected first bytes.
|
||||
inline constexpr std::uintptr_t fast_travel_getter = 0x87b640;
|
||||
inline constexpr std::array<unsigned char, 32> fast_travel_getter_prefix{0x48,0x83,0xec,0x28,0x48,0x8b,0x0d,0x95,0x76,0xc0,0x06,0x48,0x8d,0x15,0xae,0x23,0x9e,0x06,0xe8,0x99,0xb8,0x05,0x01,0x48,0x85,0xc0,0x74,0x0d,0x80,0x78,0x29,0x00};
|
||||
|
||||
// Settings sections: TypeInfo handle (settings lookup key) and vtable.
|
||||
// DelMarGameSettings is engine::game_settings_handle / engine::game_settings_vtable.
|
||||
inline constexpr std::uintptr_t activity_settings_handle = 0x725cc18;
|
||||
inline constexpr std::uintptr_t activity_settings_vtable = 0x6146340;
|
||||
inline constexpr std::uintptr_t advance_settings_handle = 0x725da00;
|
||||
inline constexpr std::uintptr_t advance_settings_vtable = 0x61464a0;
|
||||
inline constexpr std::uintptr_t player_progression_settings_handle = 0x7243cb0;
|
||||
inline constexpr std::uintptr_t player_progression_settings_vtable = 0x612ebb0;
|
||||
inline constexpr std::uintptr_t ui_settings_handle = 0x7257fe8;
|
||||
inline constexpr std::uintptr_t ui_settings_vtable = 0x6141688;
|
||||
inline constexpr std::uintptr_t progression_settings_handle = 0x725d5f0;
|
||||
inline constexpr std::uintptr_t progression_settings_vtable = 0x61463e8;
|
||||
|
||||
// Reflection member records ({name hash, offset, type}) of the overridden
|
||||
// boolean fields, named after the field.
|
||||
// DingoActivitySettings
|
||||
inline constexpr std::uintptr_t force_all_activities_enabled_member = 0x82ff060;
|
||||
inline constexpr std::uintptr_t use_dev_enabled_activities_member = 0x82ff090;
|
||||
inline constexpr std::uintptr_t enable_quest_system_member = 0x82ff120;
|
||||
inline constexpr std::uintptr_t enable_action_system_member = 0x82ff240;
|
||||
inline constexpr std::uintptr_t enable_narrative_member = 0x82ff318;
|
||||
inline constexpr std::uintptr_t enable_activity_spawned_waypoints_member = 0x82ff4e0;
|
||||
inline constexpr std::uintptr_t enable_quest_system_debug_member = 0x82ff138;
|
||||
inline constexpr std::uintptr_t force_use_mock_quest_member = 0x82ff1f8;
|
||||
inline constexpr std::uintptr_t show_local_quests_member = 0x82ff288;
|
||||
inline constexpr std::uintptr_t auto_claim_quests_member = 0x82ff2d0;
|
||||
inline constexpr std::uintptr_t disable_quest_order_filtering_member = 0x82ff330;
|
||||
inline constexpr std::uintptr_t dev_workflows_enabled_member = 0x82ff498;
|
||||
// DingoAdvanceSettings
|
||||
inline constexpr std::uintptr_t fast_travel_points_enabled_member = 0x82fea00;
|
||||
inline constexpr std::uintptr_t board_wear_enabled_member = 0x82fe9b8;
|
||||
inline constexpr std::uintptr_t player_vs_player_collision_enabled_member = 0x82fe9d0;
|
||||
// PlayerProgressionSystemSettings
|
||||
inline constexpr std::uintptr_t enable_player_progression_member = 0x82ed7e8;
|
||||
inline constexpr std::uintptr_t enable_graphs_member = 0x82ed800;
|
||||
// DelMarGameSettings
|
||||
// DelMarUISettings
|
||||
inline constexpr std::uintptr_t enable_dev_only_menu_member = 0x82fa6f8;
|
||||
inline constexpr std::uintptr_t enable_content_browser_ui_member = 0x82fa710;
|
||||
inline constexpr std::uintptr_t enable_legacy_progression_menu_member = 0x82fa728;
|
||||
inline constexpr std::uintptr_t enable_new_map_member = 0x82fa8f0;
|
||||
inline constexpr std::uintptr_t enable_old_buildkit_object_browser_member = 0x82faa10;
|
||||
// DingoProgressionSettings
|
||||
inline constexpr std::uintptr_t enable_neighborhood_rank_member = 0x82fe0d0;
|
||||
inline constexpr std::uintptr_t enable_competitive_rank_member = 0x82fe0e8;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::global_offline {
|
||||
// Shared OnlineEnabled leaf hooked by the global offline experiment.
|
||||
inline constexpr std::uintptr_t online_enabled = 0x3b8830;
|
||||
// MOV AL,1; RET followed by the inspected INT3 padding.
|
||||
inline constexpr std::array<unsigned char, 16> online_enabled_prefix{
|
||||
0xb0,0x01,0xc3,0xcc,0xcc,0xcc,0xcc,0xcc,
|
||||
0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc};
|
||||
// Return addresses of the direct OnlineEnabled calls the experiment answers.
|
||||
inline constexpr std::uintptr_t backend_predicate_return = 0x2830c4;
|
||||
inline constexpr std::uintptr_t async_prepare_character_return = 0x7fe2f2;
|
||||
inline constexpr std::uintptr_t character_readiness_return = 0x8070d5;
|
||||
// Return address inside the reflected Flow getIsOnlineEnabled invocation adapter.
|
||||
inline constexpr std::uintptr_t flow_adapter_return = 0x58e2abe;
|
||||
// Post-spawn progression guard.
|
||||
inline constexpr std::uintptr_t post_spawn_progression_return = 0x82ab99;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include "engine.h"
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::graph_throttle {
|
||||
// What a loaded graph's delayed re-post slots are bound to before the throttle
|
||||
// takes them over.
|
||||
inline constexpr std::uintptr_t post_seconds_fast = engine::post_event_delayed_seconds;
|
||||
inline constexpr std::uintptr_t post_frames_fast = engine::post_event_delayed_frames;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include "engine.h"
|
||||
|
||||
namespace dingosdk::game::build::v20260929::graphics_controls {
|
||||
// Filmic component constructor, destructor and property setter; the settings
|
||||
// manager lookup; the video filter pass setup.
|
||||
inline constexpr std::array<Fingerprint, 5> graphics_sites{{
|
||||
{0x52bcc20, {0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x48,0x81,0xec,0x00,0x01,0x00,0x00,0x44,0x0f,0xb7,0x05,0x1a,0x04,0x60,0x02,0x48,0x8b,0xd9,0x48,0x8d,0x4c,0x24,0x40,0x48,0x8b}},
|
||||
{0x52b94a0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x05,0x77,0xc2,0x51,0x01,0x48,0x8b,0xf9,0x48,0x89,0x01,0x8b,0xda,0x48,0x83,0xc1,0x28,0xe8,0x4e,0x34}},
|
||||
{0x52b9390, {0x48,0x89,0x74,0x24,0x18,0x57,0x48,0x83,0xec,0x20,0x4c,0x8b,0x4a,0x08,0x48,0x8b,0xf1,0x44,0x8b,0x02,0x48,0x8d,0x51,0x40,0x48,0x8b,0x49,0x08,0xe8,0x1f,0x2e,0xd1}},
|
||||
{engine::settings_lookup, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x59,0x70,0x48,0x8b,0xf9,0x48,0x8b,0xcb,0x48,0x8b,0xf2,0xe8,0x6f,0x8e,0xcb}},
|
||||
{0x5e2d60, {0x48,0x89,0x5c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x55,0x57,0x41,0x56,0x48,0x8d,0x6c,0x24,0xb9,0x48,0x81,0xec,0xd0,0x00,0x00,0x00,0x48,0x8b,0x01,0x48,0x8b,0xf1}},
|
||||
}};
|
||||
// DingoVideoFilterRenderPass and its module; the pass caches the video effect mask.
|
||||
inline constexpr std::uintptr_t video_filter_pass_vtable = 0x60ca0d8;
|
||||
inline constexpr std::uintptr_t video_filter_module_vtable = 0x60ca020;
|
||||
// Filmic component vtable (destructor at +8, property setter at +0x18) and its data TypeInfo.
|
||||
inline constexpr std::uintptr_t filmic_vtable = 0x67d57f8;
|
||||
inline constexpr std::uintptr_t filmic_data_type = 0x78c6968;
|
||||
// GlobalPostProcessSettings TypeInfo. The native renderer settings service
|
||||
// (3bc72f0) caches PostProcess copies by its DdcDirtyVersion.
|
||||
inline constexpr std::uintptr_t post_process_settings_type = 0x76f54e8;
|
||||
// GlobalPostProcessSettings reflected field records.
|
||||
inline constexpr std::uintptr_t film_grain_enable_field = 0x8407cb8;
|
||||
inline constexpr std::uintptr_t vignette_enable_field = 0x8407b80;
|
||||
inline constexpr std::uintptr_t ddc_dirty_version_field = 0x8408720;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Fallback labels for the TierManager graphics settings cache.
|
||||
namespace dingosdk::game::build::v20260929::graphics_labels {
|
||||
// TierManager settings cache rebuild, void(manager).
|
||||
inline constexpr std::uintptr_t tier_rebuild = 0x5fed70;
|
||||
inline constexpr std::array<unsigned char, 32> tier_rebuild_prefix{
|
||||
0x40,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,
|
||||
0x24,0xd8,0x48,0x81,0xec,0x28,0x01,0x00,0x00,0x48,0x8b,0x91,0xd0,0x01,0x00,0x00};
|
||||
// TierManager vtable.
|
||||
inline constexpr std::uintptr_t tier_manager_vtable = 0x6092548;
|
||||
// Expected first bytes of engine::string_assign.
|
||||
inline constexpr std::array<unsigned char, 18> string_assign_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x10,0x48,0x89,0x6c,0x24,0x18,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x20};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::initfs {
|
||||
// 16-byte InitFS AES key in the executable image.
|
||||
inline constexpr std::uintptr_t key = 0x71901c8;
|
||||
// Code in the InitFS decryptor that references the key.
|
||||
inline constexpr std::uintptr_t decrypt_reference = 0x41bd5d9;
|
||||
inline constexpr std::array<unsigned char, 18> decrypt_reference_prefix{
|
||||
0xc5,0xf8,0x10,0x05,0xe7,0x2b,0xfd,0x02,0x8b,0x45,0x18,
|
||||
0x48,0x8d,0x0d,0xdd,0x2b,0xfd,0x02};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// The engine's job system (worker loop 0x3221db0, run-until-done 0x3222210, job pop 0x3226a30).
|
||||
// See analysis/sim-performance.md.
|
||||
namespace dingosdk::game::build::v20260929::job_system {
|
||||
// How long an idle worker keeps polling its schedulers and queues before it sleeps, in timestamp
|
||||
// counter ticks. Computed once at job-system start (0x3227660) from spin_milliseconds; both
|
||||
// loops read it live on every pass, so a new value applies at once.
|
||||
inline constexpr std::uintptr_t spin_ticks = 0x760a670;
|
||||
// The configured spin time in milliseconds (float, 0.25 in this build; job config + 0x1a0).
|
||||
inline constexpr std::uintptr_t spin_milliseconds = 0x760aa10;
|
||||
// The number of job threads started (int).
|
||||
inline constexpr std::uintptr_t thread_count = 0x7607724;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
|
||||
namespace dingosdk::game::build::v20260929::level_loading {
|
||||
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
|
||||
// Client update writes client+0xc4 then calls void(client, next:u32, previous:u32).
|
||||
// Check both the callback entry and the caller's argument/state-store sequence.
|
||||
inline constexpr Fingerprint level_transition_contract{0x2e6bf40, {
|
||||
0x48,0x89,0x5c,0x24,0x20,0x55,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,
|
||||
0xf0,0x48,0x81,0xec,0x10,0x01,0x00,0x00,0x48,0x8b,0x05,0x61,0x84,0x35,0x04,0x48}};
|
||||
inline constexpr Fingerprint level_transition_caller_contract{0x2e773ce, {
|
||||
0x44,0x8b,0xc7,0x89,0xab,0xc4,0x00,0x00,0x00,0x8b,0xd5,0x48,0x8b,0xcb,0xe8,0x5f,
|
||||
0x4b,0xff,0xff,0x48,0x8b,0x15,0x00,0x16,0x61,0x04,0xb9,0x20,0x00,0x00,0x00,0xe8}};
|
||||
inline constexpr Fingerprint level_request_contract{0x2e722b0, {
|
||||
0x89,0x54,0x24,0x10,0x48,0x83,0xec,0x28,0x4c,0x8b,0xc1,0x48,0x81,0xc1,0xe0,0x00,
|
||||
0x00,0x00,0x83,0x79,0x40,0x00,0x74,0x11,0x49,0x8b,0x80,0xe8,0x00,0x00,0x00,0x39}};
|
||||
// Native connection closes independently of the ReSkate Steam session.
|
||||
inline constexpr Fingerprint connection_disconnect_contract{0x2e9f090, {
|
||||
0x81,0xfa,0xcd,0xbe,0x2c,0x10,0x74,0x12,0x81,0xb9,0x24,0x01,0x00,0x00,0xcd,0xbe,
|
||||
0x2c,0x10,0x75,0x06,0x89,0x91,0x24,0x01,0x00,0x00,0x8b,0x81,0x20,0x01,0x00,0x00}};
|
||||
// RimeLoadScreen::selectInfo(this, boot): called before the next description is
|
||||
// delivered during local root reloads. Return an authored RimeLoadScreenInfo.
|
||||
inline constexpr Fingerprint loading_screen_select_contract{0x43d3d00, {
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x83,0x79,0x40,0x00,0x48,
|
||||
0x8b,0xd9,0x0f,0xb6,0xfa,0x75,0x0d,0x33,0xc0,0x48,0x8b,0x5c,0x24,0x30,0x48,0x83}};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::live_mods {
|
||||
template<std::size_t N> consteval auto hex_bytes(const char (&hex)[N]) {
|
||||
static_assert(N % 2 == 1);
|
||||
std::array<std::uint8_t, (N - 1) / 2> result{};
|
||||
const auto nibble = [](char ch) -> unsigned {
|
||||
if (ch >= '0' && ch <= '9') return static_cast<unsigned>(ch - '0');
|
||||
if (ch >= 'a' && ch <= 'f') return static_cast<unsigned>(ch - 'a' + 10);
|
||||
throw "Invalid native contract hex";
|
||||
};
|
||||
for (std::size_t index = 0; index < result.size(); ++index)
|
||||
result[index] = static_cast<std::uint8_t>((nibble(hex[index * 2]) << 4) | nibble(hex[index * 2 + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Superbundle registry (the layer set's +0x58). register(registry, name)
|
||||
// creates and indexes a superbundle the way layout.toc's superBundles list
|
||||
// does at launch; find(registry, name, prefixed) looks one up by name; attach
|
||||
// (superbundle, 0, install chunk, 1) lists it under an install chunk, which is
|
||||
// what lets its bundles be located. All run under the layout manager's lock.
|
||||
inline constexpr std::uintptr_t register_superbundle = 0x41de0e0;
|
||||
inline constexpr auto register_superbundle_prefix = hex_bytes("48895c241048897424185557415441564157488bec4883ec");
|
||||
inline constexpr std::uintptr_t find_superbundle = 0x41e5cb0;
|
||||
inline constexpr auto find_superbundle_prefix = hex_bytes("48895c24184889742420574881ecd0000000");
|
||||
inline constexpr std::uintptr_t attach_superbundle = 0x41dfb70;
|
||||
inline constexpr auto attach_superbundle_prefix = hex_bytes("48895c242056574155415641574883ec30488b4120488bf1");
|
||||
|
||||
// Object layout.
|
||||
inline constexpr std::uintptr_t manager_lock = 0x130; // CRITICAL_SECTION on the layout manager
|
||||
inline constexpr std::uintptr_t layers_registry = 0x58; // layer set -> superbundle registry
|
||||
inline constexpr std::uintptr_t registry_manifest = 0x28; // registry -> install manifest
|
||||
inline constexpr std::uintptr_t manifest_chunks = 0x28; // install manifest -> chunk pointer vector (begin, end)
|
||||
inline constexpr std::uintptr_t chunk_name = 0x100; // install chunk -> name string
|
||||
|
||||
// A registered superbundle's mounts (vector of pointers at +0x30), each holding
|
||||
// the TOC every data layer supplied for it (vector of 0x28-byte entries at
|
||||
// +0x50, top layer first). An entry keeps the TOC file object at +0x10 and the
|
||||
// start of its data (past the 0x22c-byte file header) at +0x18 and +0x20; the
|
||||
// file object owns the whole file at +0x28, its size at +0x30 and +0x38.
|
||||
// Bundles are looked up in that data when they load, so a TOC rewritten on
|
||||
// disk can be handed to the game by swapping these pointers.
|
||||
inline constexpr std::uintptr_t superbundle_mounts = 0x30;
|
||||
inline constexpr std::uintptr_t mount_tocs = 0x50;
|
||||
inline constexpr std::size_t toc_entry_size = 0x28;
|
||||
inline constexpr std::uintptr_t toc_entry_file = 0x10;
|
||||
inline constexpr std::uintptr_t toc_entry_data = 0x18;
|
||||
inline constexpr std::uintptr_t toc_file_bytes = 0x28;
|
||||
inline constexpr std::uintptr_t toc_file_size = 0x30;
|
||||
inline constexpr std::size_t toc_header = 0x22c;
|
||||
// Converts a TOC's data from the file's big-endian words to native order, in
|
||||
// place (data = file + toc_header); the loader runs it on every TOC it mounts.
|
||||
inline constexpr std::uintptr_t prepare_toc = 0x41f8ed0;
|
||||
inline constexpr auto prepare_toc_prefix = hex_bytes("4883ec28c5fa6f01c5fa6f15");
|
||||
|
||||
// The root level's LevelDescriptionAsset, loaded from globals at launch only. Its
|
||||
// OnDemandBundles array ({char* SuperBundle; u32 BundleHash; pad} per entry,
|
||||
// element count in the u32 before the first element) is how the root finds a
|
||||
// map's bundle; a map added while the game runs is appended to it in memory.
|
||||
inline constexpr char root_description[] = "levels/game/dingolevel_root/dingolevel_root/description";
|
||||
inline constexpr std::uintptr_t description_on_demand = 0x40;
|
||||
inline constexpr std::size_t on_demand_entry_size = 0x10;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// The select hook itself is level_loading::loading_screen_select_contract.
|
||||
namespace dingosdk::game::build::v20260929::loading_screen {
|
||||
// RimeLoadScreen vtable.
|
||||
inline constexpr std::uintptr_t screen_vtable = 0x65a75f0;
|
||||
// RimeLoadScreenConfig TypeInfo.
|
||||
inline constexpr std::uintptr_t config_type = 0x7767300;
|
||||
// RimeLoadScreenInfo TypeInfo.
|
||||
inline constexpr std::uintptr_t info_type = 0x77671e8;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Local challenge catalog and completion delivery. Related native code: the
|
||||
// real activity catalog's anonymous model allocation 1417e18c0; progress
|
||||
// hydration 140735900 (uses GetModelField); OnAmpActivityEndRecieved_Global
|
||||
// sender 1406de170 (rejects an empty EID); 1415b4d90 places a graph's page
|
||||
// table after its aligned frame size; the criterion value copy constructor
|
||||
// 14591b5e0 treats its array fields as reference-counted ValueRefs.
|
||||
namespace dingosdk::game::build::v20260929::local_challenge {
|
||||
// Challenge (activity) owner system.
|
||||
inline constexpr std::uintptr_t challenge_owner_vtable = 0x6089158;
|
||||
// Pointer to the game context that owns the challenge system.
|
||||
inline constexpr std::uintptr_t challenge_context = 0x73c7cb0;
|
||||
// uint32 registered offset of the challenge owner in that context.
|
||||
inline constexpr std::uintptr_t challenge_owner_offset = 0x7201838;
|
||||
// uint16 reflected sizes of ChallengeCounts and ActivityData.
|
||||
inline constexpr std::uintptr_t challenge_counts_size = 0x7f15396;
|
||||
inline constexpr std::uintptr_t activity_data_size = 0x7f16196;
|
||||
// Reflected ActivityData criterion-group field record (hash 0x571954a7, +0x30, UInt64).
|
||||
inline constexpr std::uintptr_t activity_criteria_field = 0x82dd3c0;
|
||||
// Model TypeInfos of the challenge catalog.
|
||||
inline constexpr std::uintptr_t activity_data_type = 0x7223880;
|
||||
inline constexpr std::uintptr_t activity_kind_type = 0x72238e8;
|
||||
inline constexpr std::uintptr_t criterion_group_type = 0x7223940;
|
||||
inline constexpr std::uintptr_t criterion_type = 0x72239f0;
|
||||
// ActivityListUpdated event type.
|
||||
inline constexpr std::uintptr_t activity_list_updated_type = 0x7223fe8;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Retail OwnableData catalog bridge. Related native code: OwnableData decoder
|
||||
// 141e2e350 (tag 0xba, field 23 -> rarityId +160); protobuf reader constructor
|
||||
// 1420c8450; trivial CoreAllocator adapter 1401f4230; inventory manager
|
||||
// constructor 14094dc10 with its normal subscriptions 1406bc560; 140695b00
|
||||
// stores the borrowed OwnableData pointer at +0x1d0; default-owned
|
||||
// subscription callback 1406a0c30 -> 1406b6870.
|
||||
namespace dingosdk::game::build::v20260929::local_cosmetic_catalog {
|
||||
// Shared control block vtable for a native OwnableData message.
|
||||
inline constexpr std::uintptr_t message_control_vtable = 0x60736a8;
|
||||
// OwnableData (cosmetic UI) manager.
|
||||
inline constexpr std::uintptr_t ownable_manager_vtable = 0x60d2600;
|
||||
// Ownable inventory manager.
|
||||
inline constexpr std::uintptr_t inventory_manager_vtable = 0x60d20e8;
|
||||
// Pointer to the OwnableData manager.
|
||||
inline constexpr std::uintptr_t ownable_manager = 0x71f5ee0;
|
||||
// Pointer to the ownable inventory manager.
|
||||
inline constexpr std::uintptr_t inventory_manager = 0x71f4278;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_customization {
|
||||
// RecipeTemplate TypeInfo (starter character recipe templates).
|
||||
inline constexpr std::uintptr_t recipe_template_type = 0x7229390;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Offline location travel catalog. The owner pointer is engine::location_owner;
|
||||
// the native hooks are in location_travel.
|
||||
namespace dingosdk::game::build::v20260929::local_location_travel {
|
||||
// Location travel owner vtable.
|
||||
inline constexpr std::uintptr_t owner_vtable = 0x60d1e48;
|
||||
// Data model TypeInfos of a travel location and an access point.
|
||||
inline constexpr std::uintptr_t location_type = 0x7251610;
|
||||
inline constexpr std::uintptr_t access_point_type = 0x7251828;
|
||||
// Event types dispatched once the catalogs are published.
|
||||
inline constexpr std::array<std::uintptr_t, 2> notify_event_types{0x72519e8, 0x7251a40};
|
||||
// Type records (uint32 hash at +0, uint16 size at +6) of the 0x28-byte access
|
||||
// point, the 0xc0-byte location and two 1-byte types.
|
||||
inline constexpr std::uintptr_t access_point_record = 0x7f67560;
|
||||
inline constexpr std::uintptr_t location_record = 0x7f676b0;
|
||||
inline constexpr std::array<std::uintptr_t, 2> byte_records{0x7f67330, 0x7f672c0};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include "engine.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_music {
|
||||
// Registered music assets
|
||||
// Pointer to the music asset manager. Its CRITICAL_SECTION at +0x20 is taken
|
||||
// through the game's IAT thunks (0x158fd80/0x1590860) by registration (0x43e260,
|
||||
// keyed by "artist - title") and unregistration (0x449e70).
|
||||
inline constexpr std::uintptr_t asset_manager = 0x71e39a0;
|
||||
// MusicGraphAsset vtable.
|
||||
inline constexpr std::uintptr_t graph_asset_vtable = 0x6204638;
|
||||
// Music metadata (artist/title) vtable.
|
||||
inline constexpr std::uintptr_t metadata_vtable = 0x60f72a8;
|
||||
// Music TagRef vtable (enumeration + selection).
|
||||
inline constexpr std::uintptr_t tag_vtable = 0x60f56a8;
|
||||
// Tag selection (enum value) vtable.
|
||||
inline constexpr std::uintptr_t tag_selection_vtable = 0x61ec7f0;
|
||||
|
||||
// Music UI
|
||||
// Pointer to the music UI manager.
|
||||
inline constexpr std::uintptr_t ui_manager = 0x71f5ed0;
|
||||
inline constexpr std::uintptr_t ui_manager_vtable = 0x60d2588;
|
||||
// Shared control block vtables of the playlist and song DTOs, and their expected slots.
|
||||
inline constexpr std::uintptr_t playlist_control_vtable = 0x6070f48;
|
||||
inline constexpr std::uintptr_t song_control_vtable = 0x6070f68;
|
||||
inline constexpr std::array<std::uintptr_t, 4> playlist_control_slots{0x2106d0, engine::ui_control_destroy, 0x2143c0, 0x1fe380};
|
||||
inline constexpr std::array<std::uintptr_t, 4> song_control_slots{0x25f1f0, engine::ui_control_destroy, 0x274ea0, 0x1fe380};
|
||||
// Playlist and song DTO constructors.
|
||||
inline constexpr std::uintptr_t construct_playlist = 0x1d47820;
|
||||
inline constexpr std::uintptr_t construct_song = 0x1d47a40;
|
||||
// Publish the playlist and song DTO lists into the UI manager.
|
||||
inline constexpr std::uintptr_t publish_playlists = 0x6e88d0;
|
||||
inline constexpr std::uintptr_t publish_songs = 0x6e8e30;
|
||||
// Inserts a context into the UI manager's context map.
|
||||
inline constexpr std::uintptr_t context_insert = 0x6c6120;
|
||||
// Invokes the initialization completion delegate.
|
||||
inline constexpr std::uintptr_t complete_delegate = 0x58b1f00;
|
||||
// Music UI initializer (hook target).
|
||||
inline constexpr Fingerprint music_ui_initialize_contract{0x868760, {0x48,0x89,0x6c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x49,0x8b,0xe9,0x4d,0x8b,0xf0,0x4c,0x8b,0xfa}};
|
||||
inline constexpr std::array<Fingerprint, 7> music_ui_contracts{{
|
||||
music_ui_initialize_contract,
|
||||
{construct_playlist, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x21,0x95,0x32,0x04,0xe8,0x94,0x30,0x5e,0x00,0x33,0xc9,0x48,0x8d}},
|
||||
{construct_song, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x29,0x93,0x32,0x04,0xe8,0x74,0x2e,0x5e,0x00,0x33,0xc9,0x48,0x8d}},
|
||||
{publish_playlists, {0x48,0x8b,0xc4,0x48,0x89,0x50,0x10,0x55,0x53,0x57,0x48,0x8d,0x68,0xa1,0x48,0x81,0xec,0xa0,0x00,0x00,0x00,0x48,0x89,0x70,0xe0,0x48,0x8d,0xb9,0x98,0x01,0x00,0x00}},
|
||||
{publish_songs, {0x48,0x8b,0xc4,0x48,0x89,0x50,0x10,0x55,0x53,0x41,0x54,0x48,0x8d,0x68,0xa1,0x48,0x81,0xec,0x90,0x00,0x00,0x00,0x48,0x89,0x70,0xe0,0x48,0x8b,0xda,0x48,0x89,0x78}},
|
||||
{context_insert, {0x40,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x38,0x49,0x8b,0xf9,0x49,0x8b,0xd8,0x48,0x8b,0xf2,0x4c,0x8b,0xf9,0x33,0xed,0x48}},
|
||||
{complete_delegate, {0x48,0x83,0xec,0x28,0x48,0x8b,0x11,0x48,0x8b,0x0a,0x48,0x8b,0x41,0x40,0x48,0x85,0xc0,0x74,0x0d,0x80,0x79,0x68,0x00,0x75,0x07,0xff,0xd0,0x48,0x83,0xc4,0x28,0xc3}}
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_native_settings {
|
||||
// Pointer to DingoProfileSettingsManager; its first member is a vector of the
|
||||
// three option groups (LocalPerDevice, then the two cloud groups).
|
||||
inline constexpr std::uintptr_t profile_settings_manager = 0x71eff00;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Neighborhood (district) progression records. The native response conversion
|
||||
// 140346f80 hashes ProgressionId, then 14065c280 copies that hash into the base
|
||||
// record (+0x38).
|
||||
namespace dingosdk::game::build::v20260929::local_neighborhood {
|
||||
// Neighborhood record model TypeInfo.
|
||||
inline constexpr std::uintptr_t neighborhood_type = 0x72517d0;
|
||||
// Reflected neighborhood record type: uint32 name hash (0xdcf37866) and uint16 size.
|
||||
inline constexpr std::uintptr_t neighborhood_record_hash = 0x7f675d0;
|
||||
inline constexpr std::uintptr_t neighborhood_record_size = 0x7f675d6;
|
||||
// Reflected field records: Bool at +0xbd (hash 0x124511ee) and the UInt32
|
||||
// progression hash at +0x38 (hash 0x9444f3fc).
|
||||
inline constexpr std::uintptr_t neighborhood_flag_field = 0x82f7df8;
|
||||
inline constexpr std::uintptr_t neighborhood_progression_field = 0x82d0028;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_object {
|
||||
// TypeInfo registered on the Build Kit category result before the native parser
|
||||
// (profile::object_parse_contract, 0x7b7840) fills it.
|
||||
inline constexpr std::uintptr_t category_result_type = 0x7210018;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_placements {
|
||||
// Placed-object manager vtable; its id -> entity hash table starts at +0x10.
|
||||
inline constexpr std::uintptr_t placement_manager_vtable = 0x60e0380;
|
||||
// uint32 registration offset of the server player's component holding the native
|
||||
// simulation owner (+0x98). 0x7b3470/0x7befd0 activate rendering and collision
|
||||
// only for that owner.
|
||||
inline constexpr std::uintptr_t player_component_offset = 0x6f7cf60;
|
||||
// uint32 offset of the client context record whose +0x100 byte is 1 once ready.
|
||||
inline constexpr std::uintptr_t client_ready_offset = 0x7215038;
|
||||
// Pointer to the teleport manager; its first uint32 is 0 while idle.
|
||||
inline constexpr std::uintptr_t teleport_manager = 0x71ee790;
|
||||
// Reference only (PlacementCreateRecipe mirrors these, nothing calls them): the
|
||||
// create-message handler 0x87da20; the free-roam drop 0x7af5b0, whose park
|
||||
// context byte (127) comes from 0x6f7c750; the source enum codec 0x58f96c0/0x58f8c50.
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_player_card {
|
||||
// Appearance (RIP Card recipe) manager, read through engine::appearance_manager.
|
||||
inline constexpr std::uintptr_t appearance_manager_vtable = 0x6084c28;
|
||||
// TypeInfo of the UIPlayerInfo presence connection-state value (field 7).
|
||||
inline constexpr std::uintptr_t connection_state_type = 0x7258ef8;
|
||||
// TypeInfo of the UIPlayerInfo player network (field 10, record +0xf4): the platform
|
||||
// logo beside the name follows it. Values as EadpPlayerNetwork: 0 unknown (shown as EA),
|
||||
// 1 EA, 2 PSN, 3 Xbox, 4 Steam, 5 Nintendo, 6 Apple, 7 Google.
|
||||
inline constexpr std::uintptr_t player_network_type = 0x723b8f8;
|
||||
inline constexpr std::uint32_t steam_network = 4;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_rip_score {
|
||||
// Collection-score progression model TypeInfo.
|
||||
inline constexpr std::uintptr_t rip_score_type = 0x7253080;
|
||||
// Reflected record type: uint32 name hash (0x3b7ce311) and uint16 size (0x70).
|
||||
inline constexpr std::uintptr_t rip_score_record_hash = 0x7f66ed0;
|
||||
inline constexpr std::uintptr_t rip_score_record_size = 0x7f66ed6;
|
||||
// Reflected Int64 total field record (hash 0x2891685a, +0x10).
|
||||
inline constexpr std::uintptr_t rip_score_total_field = 0x82f8070;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::localization {
|
||||
// The string-override store every localized-text lookup checks before the shipped string
|
||||
// database (hasString 0x3320c70 and getString 0x3322fd0 both do). Retail fills its layer 0
|
||||
// from the backend's client_strings_v1 service (handler 0x268a00, lambda 0x2696d0: clear
|
||||
// the layer with 0x331d060, then set each record). An id found nowhere is shown as written.
|
||||
// Ids are hashed h = h * 33 + byte from 0xffffffff (0x3325670).
|
||||
// Address store(): the store (a static at +0x761b410).
|
||||
inline constexpr std::uintptr_t override_store = 0x331d5e0;
|
||||
inline constexpr std::array<unsigned char, 19> override_store_prefix{
|
||||
0x48,0x83,0xec,0x38,0x8b,0x0d,0xf6,0x6a,0x5b,0x04,0x65,0x48,0x8b,0x04,0x25,0x58,0x00,0x00,0x00};
|
||||
// bool set(Address store, const char* id, const char* utf8, int layer): takes the store's
|
||||
// lock, converts the text to UTF-16 and registers its glyphs. Engine allocator: game thread.
|
||||
inline constexpr std::uintptr_t override_set = 0x331cc20;
|
||||
inline constexpr std::array<unsigned char, 19> override_set_prefix{
|
||||
0x40,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x8d,0xac,0x24,0x70,0xfc,0xff,0xff};
|
||||
// bool has(uint32 hash): the id is in the store or the database.
|
||||
inline constexpr std::uintptr_t string_exists = 0x3320c70;
|
||||
inline constexpr std::array<unsigned char, 19> string_exists_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x8b,0xd9,0xe8,0x63,0xc9,0xff,0xff,0x8b,0xd3,0x48,0x8b,0xc8,0xe8};
|
||||
// set() encodes the text against the loaded string database (0x3325800: UTF-8 to UTF-16,
|
||||
// each character registered with the database 0x3320730, then 0x3323d90 writes the stored
|
||||
// form). With no database loaded yet it stores an empty string, which shows as blank.
|
||||
// The database is found as that function finds it: the qword at database_loaded must be
|
||||
// set; then the first of the {u32 in use (0 = this one), pad, entry*} rows at *databases,
|
||||
// indices 0..*database_last, whose entry +0x10 is the database.
|
||||
inline constexpr std::uintptr_t database_loaded = 0x761b4a0;
|
||||
inline constexpr std::uintptr_t databases = 0x761b4a8;
|
||||
inline constexpr std::uintptr_t database_last = 0x716d524;
|
||||
} // namespace dingosdk::game::build::v20260929::localization
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
|
||||
namespace dingosdk::game::build::v20260929::location_travel {
|
||||
inline constexpr Fingerprint travel_location_ctor_contract{0x59c4740, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x3d,0xae,0x9f,0x93,0x01,0x33,0xf6,0x48,0x89,0x31,0x48,0x8b,0xd9,0x48,0x89}};
|
||||
inline constexpr Fingerprint travel_access_ctor_contract{0x59c44d0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x3d,0x23,0xa2,0x93,0x01,0x48,0x8b,0xd9,0x48,0x89,0x39,0x48,0x89,0x79,0x08,0x48,0x89,0x79,0x10,0xe8}};
|
||||
inline constexpr Fingerprint travel_request_contract{0x79c9e0, {0x48,0x8b,0xc1,0x48,0x8b,0xd,0x4e,0x28,0xa5,0x6,0x48,0x85,0xc9,0x74,0xf,0xc6,0x41,0x1a,0x1,0x4c,0x8b,0x2,0x48,0x8b,0x10,0xe9,0xc2,0x6e,0xde,0xff,0xc3,0xcc}};
|
||||
inline constexpr Fingerprint travel_prepare_contract{0x786cd0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xfa,0x48,0x8b,0xd9,0xba,0x89,0x97,0x0f,0xbf,0x48,0x8d,0x4c,0x24,0x40,0xe8,0x51,0x01,0x13,0x01,0x48}};
|
||||
inline constexpr Fingerprint travel_attribute_request_contract{0x7c5590, {0x40,0x55,0x53,0x41,0x56,0x48,0x8d,0xac,0x24,0x40,0xfe,0xff,0xff,0x48,0x81,0xec,0xc0,0x02,0x00,0x00,0x48,0x8b,0x05,0x15,0xee,0x9f,0x06,0x48,0x33,0xc4,0x48,0x89}};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::loose_files {
|
||||
template<std::size_t N> consteval auto hex_bytes(const char (&hex)[N]) {
|
||||
static_assert(N % 2 == 1);
|
||||
std::array<unsigned char, (N - 1) / 2> result{};
|
||||
const auto nibble = [](char ch) -> unsigned {
|
||||
if (ch >= '0' && ch <= '9') return static_cast<unsigned>(ch - '0');
|
||||
if (ch >= 'a' && ch <= 'f') return static_cast<unsigned>(ch - 'a' + 10);
|
||||
throw "Invalid native contract hex";
|
||||
};
|
||||
for (std::size_t index = 0; index < result.size(); ++index)
|
||||
result[index] = static_cast<unsigned char>((nibble(hex[index * 2]) << 4) | nibble(hex[index * 2 + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
// VFS open. The Lua vfs.open binding and the engine's read-file wrapper both
|
||||
// reach this open ABI.
|
||||
inline constexpr std::uintptr_t vfs_open = 0x4b51130;
|
||||
inline constexpr auto vfs_open_prefix = hex_bytes("4c8bdc5557498dabc8f9ffff4881ec28070000488b05763267024833c4488985");
|
||||
// VFS exists.
|
||||
inline constexpr std::uintptr_t vfs_exists = 0x4b52660;
|
||||
inline constexpr auto vfs_exists_prefix = hex_bytes("40574881ec80050000488b05501d67024833c448898424700500004c8bc2c744");
|
||||
// Directory file-system factory.
|
||||
inline constexpr std::uintptr_t create_directory = 0x4b4c220;
|
||||
inline constexpr auto create_directory_prefix = hex_bytes("48895c24184889742420574883ec40488b154aac7a02488bd9b9a0000000e8ad");
|
||||
// VFS mount.
|
||||
inline constexpr std::uintptr_t vfs_mount = 0x4b53f90;
|
||||
inline constexpr auto vfs_mount_prefix = hex_bytes("40535556574883ec28488b5910488be9488bcb498bf8488bf2e8d2bda3fc488b");
|
||||
// Returns the global VFS.
|
||||
inline constexpr std::uintptr_t global_vfs = 0x4b4d410;
|
||||
inline constexpr auto global_vfs_prefix = hex_bytes("488b058919c902c3");
|
||||
// Reference taken inside mount: the factory initializes its intrusive
|
||||
// reference count at +8 and Mount takes a ref.
|
||||
inline constexpr std::uintptr_t mount_reference = 0x4b53e62;
|
||||
inline constexpr auto mount_reference_prefix = hex_bytes("4d85ff7405f041ff4708");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
|
||||
namespace dingosdk::game::build::v20260929::lua_startup {
|
||||
// Native post-startup routine hooked to run the Lua startup callbacks.
|
||||
inline constexpr Fingerprint startup_contract{0x4641d40, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x50,0x48,0x8b,0xf9,0xe8,0x3e,0x52,0x28,0xfd,0x33,0xd2,0x48,0x8b,0xc8,0x48,0x8b,0xd8,0xe8,0x61,0x56,0x28,0xfd,0x48}};
|
||||
// Executes a Lua source buffer in a context.
|
||||
inline constexpr Fingerprint execute_contract{0x18c7640, {0x48,0x83,0xec,0x38,0x48,0x8d,0x05,0xd5,0x32,0x9b,0x04,0xc6,0x44,0x24,0x28,0x00,0x48,0x89,0x44,0x24,0x20,0xe8,0xf6,0x31,0x00,0x00,0x48,0x83,0xc4,0x38,0xc3,0xcc}};
|
||||
// Returns the Lua context.
|
||||
inline constexpr Fingerprint get_context_contract{0x18c6f90, {0x48,0x83,0xec,0x28,0x48,0x8b,0x05,0x15,0xbd,0xbb,0x05,0x48,0x85,0xc0,0x0f,0x85,0xb8,0x00,0x00,0x00,0x44,0x0f,0xb6,0x44,0x24,0x30,0xb9,0x10,0x01,0x00,0x00,0x48}};
|
||||
// Reloads settings on the settings manager.
|
||||
inline constexpr Fingerprint reload_settings_contract{0x18d3360, {0x4c,0x8b,0xdc,0x55,0x53,0x49,0x8d,0xab,0x08,0xfe,0xff,0xff,0x48,0x81,0xec,0xe8,0x02,0x00,0x00,0x48,0x8b,0x05,0x46,0x10,0x8f,0x05,0x48,0x33,0xc4,0x48,0x89,0x85}};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
#include "engine.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Main mission / quest progression override.
|
||||
namespace dingosdk::game::build::v20260929::mission_progression {
|
||||
// Quest lookup by QuestId.
|
||||
inline constexpr std::uintptr_t quest_lookup = 0x825750;
|
||||
inline constexpr std::array<unsigned char, 32> quest_lookup_prefix{
|
||||
0x48,0x89,0x6c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x41,0x56,0x48,0x83,0xec,0x20,
|
||||
0x4c,0x8b,0xf1,0x49,0x8b,0xe8,0x48,0x8b,0x0d,0x73,0xd5,0xc5,0x06,0x48,0x8b,0xf2};
|
||||
// Generic ECS component query.
|
||||
inline constexpr std::uintptr_t component_query = 0x276c4f0;
|
||||
inline constexpr std::array<unsigned char, 31> component_query_prefix{
|
||||
0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x4c,0x8b,0x51,0x10,0x48,0x8b,0xd9,
|
||||
0x48,0x83,0xc1,0x08,0x41,0x8b,0xe9,0x49,0x8b,0xf0,0x4d,0x85,0xd2,0x74,0x0c};
|
||||
// Quest status component query call site: LEA the exact Quest status component
|
||||
// descriptor, initialize the 24-byte query result, and call the generic ECS
|
||||
// component query. It ends at quest_status_query_return.
|
||||
inline constexpr std::uintptr_t quest_status_query_call = 0x82cbb9;
|
||||
inline constexpr std::array<unsigned char, 29> quest_status_query_call_prefix{
|
||||
0x48,0x8d,0x15,0xb0,0xe7,0x9f,0x06,0x48,0x89,0x5d,0x0f,0x48,0x8d,0x4d,0x17,0x48,
|
||||
0x89,0x7d,0xef,0xc5,0xfa,0x7f,0x45,0xf7,0xe8,0x1a,0xf9,0xf3,0x01};
|
||||
// Return address of that query: validate both result pointers and read the
|
||||
// QuestState dword at component offset +0x18 before the caller's terminal-state test.
|
||||
inline constexpr std::uintptr_t quest_status_query_return = 0x82cbd6;
|
||||
inline constexpr std::array<unsigned char, 36> quest_state_consumer_prefix{
|
||||
0x48,0x83,0x7d,0xf7,0x00,0x0f,0x84,0xd7,0x01,0x00,0x00,0x48,0x8b,0x4d,0xff,0x48,
|
||||
0x85,0xc9,0x0f,0x84,0xca,0x01,0x00,0x00,0x8b,0x41,0x18,0x48,0x8b,0x16,0x89,0x45,
|
||||
0xcb,0x83,0xf8,0x01};
|
||||
// Quest status component descriptor.
|
||||
inline constexpr std::uintptr_t quest_status_component_descriptor = 0x722b370;
|
||||
// Expression quest lookup call site and return: checks readiness, resolves
|
||||
// QuestId, then returns that exact handle to the compiled mission/presentation graph.
|
||||
inline constexpr std::uintptr_t quest_expression_call = 0x83508c;
|
||||
inline constexpr std::uintptr_t quest_expression_return = 0x8350a5;
|
||||
inline constexpr std::array<unsigned char, 49> quest_expression_call_prefix{
|
||||
0x80,0xb8,0xd0,0x01,0x00,0x00,0x00,0x74,0x38,0x4c,0x8b,0xc6,0x48,0x8d,0x54,0x24,
|
||||
0x20,0x48,0x8b,0xc8,0xe8,0xab,0x06,0xff,0xff,0x48,0x8b,0x08,0x48,0xb8,0x00,0x00,
|
||||
0x00,0x00,0xff,0xff,0xff,0xff,0x48,0x85,0xc8,0x48,0x89,0x0f,0x0f,0x95,0xc0,0x88,0x03};
|
||||
// ECS query context constructor and initializer.
|
||||
inline constexpr std::uintptr_t query_context_constructor = engine::query_context_constructor;
|
||||
inline constexpr std::array<unsigned char, 26> query_context_constructor_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x45,0x33,0xc0,0x48,0x8b,0xd9,0xe8,0xdf,0xa3,0xfe,
|
||||
0xff,0x48,0x8b,0xc3,0x48,0x83,0xc4,0x20,0x5b,0xc3};
|
||||
inline constexpr std::uintptr_t query_context_initializer = 0x27597d0;
|
||||
inline constexpr std::array<unsigned char, 32> query_context_initializer_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x89,0x11,0x48,0x8b,0xda,
|
||||
0x48,0xc7,0x41,0x08,0x00,0x00,0x00,0x00,0x48,0x8b,0xf9,0x48,0xc7,0x41,0x10,0x00};
|
||||
// Query-result vtable and quest status type field, as built by 0x14082cab0
|
||||
// (its LEAs at 14082c71e/14082c72d feed the query at 14082c7c1).
|
||||
inline constexpr std::uintptr_t query_result_vtable = 0x60de9c8;
|
||||
inline constexpr std::uintptr_t quest_status_type = 0x722b210;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::mod_layers {
|
||||
template<std::size_t N> consteval auto hex_bytes(const char (&hex)[N]) {
|
||||
static_assert(N % 2 == 1);
|
||||
std::array<std::uint8_t, (N - 1) / 2> result{};
|
||||
const auto nibble = [](char ch) -> unsigned {
|
||||
if (ch >= '0' && ch <= '9') return static_cast<unsigned>(ch - '0');
|
||||
if (ch >= 'a' && ch <= 'f') return static_cast<unsigned>(ch - 'a' + 10);
|
||||
throw "Invalid native contract hex";
|
||||
};
|
||||
for (std::size_t index = 0; index < result.size(); ++index)
|
||||
result[index] = static_cast<std::uint8_t>(
|
||||
(nibble(hex[index * 2]) << 4) | nibble(hex[index * 2 + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
// `classify` derives a layer kind from the layout.toc path.
|
||||
inline constexpr std::uintptr_t classify = 0x41ded80;
|
||||
inline constexpr auto classify_prefix = hex_bytes("4055535657488d6c24884881ec78010000488b052856fe024833c44889456048");
|
||||
// `load` adds one layer to the layout manager.
|
||||
inline constexpr std::uintptr_t load_layer = 0x4203e60;
|
||||
inline constexpr auto load_layer_prefix = hex_bytes("48895c242055565741544157488dac2460feffff4881eca0020000488b053e05");
|
||||
// Slot 0x60 of the layout manager's vtable, which must still be `load`.
|
||||
inline constexpr std::uintptr_t load_layer_slot = 0x65924f8;
|
||||
// layout_bootstrap, which performs the LCU, Patch and Data loads in that order.
|
||||
inline constexpr std::uintptr_t layout_bootstrap = 0x41bc220;
|
||||
inline constexpr auto layout_bootstrap_prefix = hex_bytes("48895c2418488974242055574156488dac2430fdffff4881ecd0030000488b05");
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
|
||||
// RVA-only code, pdata and unwind bytes are ASLR invariant. This file is a
|
||||
// production compatibility guard, not an extracted asset or runtime dump.
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::morph_memory_pool {
|
||||
template<std::size_t N> consteval auto hex_bytes(const char (&hex)[N]) {
|
||||
static_assert(N % 2 == 1);
|
||||
std::array<std::uint8_t, (N - 1) / 2> result{};
|
||||
const auto nibble = [](char c) -> unsigned {
|
||||
if (c >= '0' && c <= '9') return static_cast<unsigned>(c - '0');
|
||||
if (c >= 'a' && c <= 'f') return static_cast<unsigned>(c - 'a' + 10);
|
||||
throw "Invalid morph contract hex";
|
||||
};
|
||||
for (std::size_t i = 0; i < result.size(); ++i)
|
||||
result[i] = static_cast<std::uint8_t>((nibble(hex[i * 2]) << 4) | nibble(hex[i * 2 + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
// SHA256 9d5371d7d80da2418e82d14fdc28bf77e0d1c26c03ea34a9249e8b4759f2710d
|
||||
inline constexpr auto constructor = hex_bytes(
|
||||
"48895c2408574883ec30488d05670c7804488bf9488901488d415848c7410800000000c5fc1002c5fc11411048894150"
|
||||
"ba0100000048894138448bc2488941304883c0404889414048c74148000000004881c198000000c5f877e8616527024c"
|
||||
"8bcf48c7442420000000004c8d05ee810000488d15c7810000488d8f98000000e85ba427024533c0488d8f98000000ba"
|
||||
"01000000e827c927024c8b4718488d8f98000000ba07000000e812c92702488b5c2440488bc74883c4305fc3");
|
||||
// SHA256 255be1a04afcfe7c13c7a2f7e327246e06d702a914c8255c889b101daf7dd580
|
||||
inline constexpr auto constructor_pdata_0 = hex_bytes(
|
||||
"8015b2013c16b20160b1ab06");
|
||||
// SHA256 95a10c6d2d751622a4535e1f8c8bdca6a784f9865cf0e1039457f8b7b56c3dae
|
||||
inline constexpr auto constructor_unwind_0 = hex_bytes(
|
||||
"010a04000a3408000a520670");
|
||||
// SHA256 607fea135c302789e7f2deae98ebdd6d31f38e5a44c945694896bc8ed3d300c8
|
||||
inline constexpr auto morph_initializer = hex_bytes(
|
||||
"48895c2408574881eca0000000488b1d74a59705488d05cd2b790448890546589a05488d05d72b790448890548589a05"
|
||||
"488d05e12b79044889054a589a0548890553589a05e8d6106ffe33ff84c00f8599010000488b054d5e9a054885c0751a"
|
||||
"488b0d194897054c8d053a5e9a05488d1553539a05e8c68adcff486348284c8d15f7f45504488d15d42a790448890d3d"
|
||||
"589a054889150e589a054533c94c89151c589a054533c0893d03589a05c705fd579a050200000048893dfa579a054889"
|
||||
"3d03589a054863402048891508589a05498bd248894c2420488d4c2450893dfd579a05c705f7579a050200000048893d"
|
||||
"f4579a054c8915f5579a0548893df6579a05488905f7579a05e8028ea8ff488b05bb579a05b920150000488b15f7887e"
|
||||
"05488b1d60a497054889442468c644247001e859f4a6ff4885c074194c8bc3488d542450488bc8e8248da8ff48890575"
|
||||
"579a05eb0748893d6c579a0548393d95579a057571488b0594579a05488d4c2450488b1578579a054533c94533c04889"
|
||||
"442420e8888da8ff488b0571579a05b920150000488b157d887e05488b1de6a397054889442468c644247001e8dff3a6"
|
||||
"ff4885c074194c8bc3488d542450488bc8e8aa8ca8ff4889052b579a05eb0748893d22579a05c605c3569a0501488d1d"
|
||||
"f4569a05488d05bd569a05eb03488bc348891d69569a0548891d82569a05488b1da35c9a0548890564569a054889057d"
|
||||
"569a054885db751d488b0d614697054c8d05825c9a05488d159b519a05e80e89dcff488bd8488b0d34569a05488b151d"
|
||||
"569a0548894c243048c74424380000010048634b3448894c2440b9700c0000c644244801e827f3a6ff4885c0740f488d"
|
||||
"542430488bc8e8a52e0100eb03488bc7488b15d9559a05b9700c000048890595569a05488b05d6559a05488944243048"
|
||||
"63432c4889442440c644244801e8def2a6ff4885c07425488d542430488bc8e85c2e010048890555569a05488b9c24b0"
|
||||
"0000004881c4a00000005fc3488b9c24b000000048893d35569a054881c4a00000005fc3");
|
||||
// SHA256 33d299717f4325f4df48939844e7c9a58b871e085d43ee6c8e515846d718f903
|
||||
inline constexpr auto morph_initializer_pdata_0 = hex_bytes(
|
||||
"60e4b00154e7b001fcc5ac06");
|
||||
// SHA256 0805341631737f6e131b8ceadb7b0046158c75a5a77488732dd00253aff37e9f
|
||||
inline constexpr auto morph_initializer_unwind_0 = hex_bytes(
|
||||
"010d05000d3416000d01140006700000");
|
||||
// SHA256 b0d0e53c6708473c5c8aaaef4e3d8ed0a497990f86a70011d80d572beb784cf9
|
||||
inline constexpr auto pool_allocate = hex_bytes(
|
||||
"40535556574154415641574883ec304d8be14d8bf84c8bf2488be90f1f440000488b4508a83f751b0f1f840000000000"
|
||||
"488bd04883ca3ff0480fb155087418a83f74ed41b801000000488d4d08488bd0e87b38a6ffebc94c8b4518488d8d9800"
|
||||
"00004533c9c744242004000000498bd7e88b852702488bf84885c07525488bcde81bfbffff4c8b4518488d8d98000000"
|
||||
"4533c9897c2420498bd7e861852702488bf848c7c2c1fffffff0480fc155084883c2c148f7c280ffffff7409488d4d08"
|
||||
"e84b73a6ff4d8bcf4c8bc7488bd5498bcee8eab53102498bd4498bcee8af5c6dfe498bc64883c430415f415e415c5f5e"
|
||||
"5d5bc3");
|
||||
// SHA256 bec574e7df6885128b3496a7f3996084aae42ba123a546d9026f0e411eee822d
|
||||
inline constexpr auto pool_allocate_pdata_0 = hex_bytes(
|
||||
"a038b2019339b201c4daab06");
|
||||
// SHA256 eb3d1b62e666c6777ab6187669de7911588d02064dc5bd980db5d82be559a7f5
|
||||
inline constexpr auto pool_allocate_unwind_0 = hex_bytes(
|
||||
"010f08000f520bf009e007c00570046003500230");
|
||||
// SHA256 82090f22003a26a5baa782117422d2b7d01692808a90ccb4ed3a3a6533f856f7
|
||||
inline constexpr auto pool_grow = hex_bytes(
|
||||
"4053555741554883ec68488b4120488bf948894424384533ed488b411848894424400fb64128488b4938488be9884424"
|
||||
"48482b6f30488d05b4ed770448c1fd03c74424303000000066c744244a01004c896c2458c6442449014889442450483b"
|
||||
"4f407310488d4108488947384c8929e9130100004889b424980000004c89a424a00000004c89b424a80000004c897c24"
|
||||
"6085ed741f81fdffffff7f73108d346d0000000085f675114d8bfdeb2cbeffffffffeb05be010000008bd6488d4f4848"
|
||||
"c1e2034533c941b80800000044896c2420e80a1ebf004c8bf88bcd4d8bc74d892ccf4c8b4f38488b4f30493bc9741790"
|
||||
"488b114c89294883c1084989104983c008493bc975ea4c8b77384d8d6008488b5f30493bde7417488b0b4885c9740648"
|
||||
"8b01ff50084883c308493bde75e9488b57304c8bb424a80000004885d27421483b5750488d4f4874174c8b47404c2bc2"
|
||||
"49c1f803458bc049c1e003e8c01dbf008bc6488bb424980000004c897f304c8967384c8ba424a0000000498d0cc74c8b"
|
||||
"7c246048894f40488b0d4258bc05488b5f38488b01ff50604c8d43f8488bc8488d5424304c8b0841ff91880000004c8b"
|
||||
"4720488d8f9800000089ac24900000004533c94489ac2494000000488b84249000000048c1e8208bd548c1e220480bd0"
|
||||
"4c896c242048b80000000000000080480bd0e8894b27024883c468415d5f5d5bc3");
|
||||
// SHA256 1c0c2e6a97b2eaa9f4277e4648cb8abaae51541b07b177b6476cd4c8ad80bcb3
|
||||
inline constexpr auto pool_grow_pdata_0 = hex_bytes(
|
||||
"4034b201b434b201b855c606");
|
||||
// SHA256 38605cfc6c0a2eae2275b9f0d12567636ffb9468d1da3e3d3f7489b0cc42b396
|
||||
inline constexpr auto pool_grow_unwind_0 = hex_bytes(
|
||||
"010a05000ac206d00470035002300000");
|
||||
// SHA256 c9e7811d183bf2c809fd4bda01f0ed59e8aa1ea13f8e25bf08700b9c43f85111
|
||||
inline constexpr auto pool_grow_pdata_1 = hex_bytes(
|
||||
"b434b201c434b201c855c606");
|
||||
// SHA256 bf698f75148e8b8d93cbee934ca4e804af2b8975b04e04bf4a1428d6f35f49d2
|
||||
inline constexpr auto pool_grow_unwind_1 = hex_bytes(
|
||||
"2110040010c41400086413004034b201b434b201b855c606");
|
||||
// SHA256 3e4df33dc716181ae3129bc7911ccd84ce6015430f4a3904ed2ed0c4fdb45f90
|
||||
inline constexpr auto pool_grow_pdata_2 = hex_bytes(
|
||||
"c434b2017f35b201e055c606");
|
||||
// SHA256 e16c924644dc1dc90eb3c68bc8a8e63ad65b1205c98cef8ea1cc7c91993ba4ac
|
||||
inline constexpr auto pool_grow_unwind_2 = hex_bytes(
|
||||
"210d04000df40c0008e41500b434b201c434b201c855c606");
|
||||
// SHA256 640c455ecd23d4077cab2e987610fe1300b6bb2fb999527e2f673b48a6b632f2
|
||||
inline constexpr auto pool_grow_pdata_3 = hex_bytes(
|
||||
"7f35b201c735b201f855c606");
|
||||
// SHA256 ec4f597441944f311d301422061c2555e58e09b218f06ebc005204950a947e43
|
||||
inline constexpr auto pool_grow_unwind_3 = hex_bytes(
|
||||
"2100020000f40c00b434b201c434b201c855c606");
|
||||
// SHA256 15353f67825a571676015f7940054e1c25ce0aeae9ecfb06285cc1eb34d380ea
|
||||
inline constexpr auto pool_grow_pdata_4 = hex_bytes(
|
||||
"c735b2014136b2010c56c606");
|
||||
// SHA256 18eed2b26ab659cfce6633d49c6818a9fba7bfc7c56d340924c23e259b1bc9e1
|
||||
inline constexpr auto pool_grow_unwind_4 = hex_bytes(
|
||||
"210000004034b201b434b201b855c606");
|
||||
// SHA256 bac5adde31da1384fc6b409a92d261a7112fb4c46d0824eb78b078cc54c08129
|
||||
inline constexpr auto pool_map_heap_offset = hex_bytes(
|
||||
"40535556574883ec28498be8488bfa488bf1488b460848a980ffffff75660fb6d080e23f80fa3e735b488d4801f0480f"
|
||||
"b14e0875e1488b4630488bcd48c1e92048c7c2ffffffff0fbaf11f488b0cc88bc54889470848890ff0480fc1560848ff"
|
||||
"caf6c23f751248f7c280ffffff7409488d4e08e8483aa6ff488bc74883c4285f5e5d5bc34533c0488d4e08488bd0e8ed"
|
||||
"fea5ffe97affffff");
|
||||
// SHA256 a45a521d57a2ff5732f925598412e0ab324d4e138c224bfecce8b955ce76acbf
|
||||
inline constexpr auto pool_map_heap_offset_pdata_0 = hex_bytes(
|
||||
"f071b2018872b20120b6ab06");
|
||||
// SHA256 f28ccee9e54971753b66d1ea62e19a750e5b147360135aca2e5cffe13c1c86eb
|
||||
inline constexpr auto pool_map_heap_offset_unwind_0 = hex_bytes(
|
||||
"01090500094205700460035002300000");
|
||||
// SHA256 4896d783b760dae884dfc92ca9df2a19f00420d05a6fd73b6e44d0ca1a2d5b90
|
||||
inline constexpr auto temporary_field_name = hex_bytes(
|
||||
"54656d7052656e6465724d656d6f7279506f6f6c457874656e6453697a6500");
|
||||
// SHA256 6705479ca5b564ada2ee65fc0f3fa22b5a5e0ce0014c88ccb697eca29df61f15
|
||||
inline constexpr auto pool_debug_name = hex_bytes(
|
||||
"44696e676f4d6f7270684d656d6f7279506f6f6c00");
|
||||
// SHA256 73eb8b851694db818e5f16ae4ce8afedba597e738d78c82e7cdb5d3923e71dfd
|
||||
inline constexpr auto settings_constructor = hex_bytes(
|
||||
"40534883ec20488bd9e8528f0004488d05fbef7a04c7432000008002488903488bc3c7432400008000c7432800008002"
|
||||
"c7432c00000001c7433000008000c743340000a0004883c4205bc3");
|
||||
|
||||
struct Contract { std::uintptr_t rva; std::span<const std::uint8_t> bytes; };
|
||||
inline constexpr Contract contracts[]{
|
||||
{0x01b21580, constructor},
|
||||
{0x08d926c4, constructor_pdata_0},
|
||||
{0x06abb160, constructor_unwind_0},
|
||||
{0x01b0e460, morph_initializer},
|
||||
{0x08d91260, morph_initializer_pdata_0},
|
||||
{0x06acc5fc, morph_initializer_unwind_0},
|
||||
{0x01b238a0, pool_allocate},
|
||||
{0x08d929dc, pool_allocate_pdata_0},
|
||||
{0x06abdac4, pool_allocate_unwind_0},
|
||||
{0x01b23440, pool_grow},
|
||||
{0x08d92970, pool_grow_pdata_0},
|
||||
{0x06c655b8, pool_grow_unwind_0},
|
||||
{0x08d9297c, pool_grow_pdata_1},
|
||||
{0x06c655c8, pool_grow_unwind_1},
|
||||
{0x08d92988, pool_grow_pdata_2},
|
||||
{0x06c655e0, pool_grow_unwind_2},
|
||||
{0x08d92994, pool_grow_pdata_3},
|
||||
{0x06c655f8, pool_grow_unwind_3},
|
||||
{0x08d929a0, pool_grow_pdata_4},
|
||||
{0x06c6560c, pool_grow_unwind_4},
|
||||
{0x01b271f0, pool_map_heap_offset},
|
||||
{0x08d92cc4, pool_map_heap_offset_pdata_0},
|
||||
{0x06abb620, pool_map_heap_offset_unwind_0},
|
||||
{0x0629fcc0, temporary_field_name},
|
||||
{0x062a2230, pool_debug_name},
|
||||
{0x01af1dc0, settings_constructor},
|
||||
};
|
||||
inline constexpr std::size_t max_contract_size = [] {
|
||||
std::size_t size = 0;
|
||||
for (const auto& contract : contracts) size = contract.bytes.size() > size ? contract.bytes.size() : size;
|
||||
return size;
|
||||
}();
|
||||
|
||||
// Morph heap constructor hooked to raise the render heap capacity.
|
||||
inline constexpr std::uintptr_t constructor_rva = 0x01b21580;
|
||||
// Return addresses of the temporary and permanent morph heap constructions.
|
||||
inline constexpr std::uintptr_t temporary_return = 0x01b0e724;
|
||||
inline constexpr std::uintptr_t permanent_return = 0x01b0e6db;
|
||||
// Globals holding the temporary and permanent morph pools (null until built).
|
||||
inline constexpr std::uintptr_t temporary_pool = 0x074b3d80;
|
||||
inline constexpr std::uintptr_t permanent_pool = 0x074b3d88;
|
||||
} // namespace dingosdk::game::build::v20260929::morph_memory_pool
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "engine.h"
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
namespace dingosdk::game::build::v20260929::named_settings {
|
||||
// Named getter/setter callers at 0x2d6dfe0..0x2d6e277 and the preset
|
||||
// parser at 0x5f77ef independently establish these ABIs. The typed setter
|
||||
// increments DdcDirtyVersion and calls registered setting listeners.
|
||||
inline constexpr std::uintptr_t named_settings_manager = engine::settings_manager;
|
||||
inline constexpr std::uintptr_t named_settings_get = 0x18d95e0;
|
||||
inline constexpr std::uintptr_t named_settings_set = 0x18d9c50;
|
||||
inline constexpr std::uintptr_t native_bool = engine::bool_type;
|
||||
inline constexpr std::uintptr_t native_int32 = engine::int32_type;
|
||||
inline constexpr std::uintptr_t native_uint32 = engine::uint32_type;
|
||||
inline constexpr std::uintptr_t native_float32 = engine::float_type;
|
||||
inline constexpr std::uintptr_t native_cstring = engine::string_type;
|
||||
// Pointer to the reflection symbol registry (count at +0xd0); populated before
|
||||
// named lookups are attempted.
|
||||
inline constexpr std::uintptr_t symbol_registry = 0x765d318;
|
||||
// Enumeration (reads only). The manager's group map (FUN_1418d2630 registers,
|
||||
// FUN_1418d2d30 walks it): buckets +0xa0, bucket count u32 +0xa8, element count
|
||||
// u32 +0xac; node {char* name, object*, ..., next +0x40}; object +8 = class
|
||||
// TypeInfo. A second map of raw structs (FUN_1418d2930): buckets +0xf0, count
|
||||
// +0xf8, elements +0xfc; node {char* name, TypeInfo*, data*, ..., next +0x48}.
|
||||
// The manager lock is the critical section at +0x70.
|
||||
inline constexpr std::size_t group_buckets = 0xa0, group_bucket_count = 0xa8, group_count = 0xac, group_next = 0x40;
|
||||
inline constexpr std::size_t raw_buckets = 0xf0, raw_bucket_count = 0xf8, raw_count = 0xfc, raw_next = 0x48;
|
||||
inline constexpr std::size_t manager_lock = 0x70;
|
||||
// Registry name map (FUN_143dabc00): buckets +0x78, count u32 +0x80; node
|
||||
// {u64 key, char* name, next +0x10}, keyed by field-data or descriptor address.
|
||||
inline constexpr std::size_t name_buckets = 0x78, name_bucket_count = 0x80;
|
||||
// TypeInfo: +0 descriptor, +0x20 super TypeInfo (FUN_143dc4960). Descriptor:
|
||||
// +4 u16 flags (kind = bits 5..9; 3 class, 2 value type), +0x2a u16 own field
|
||||
// count, fields at +0x38 (class) or +0x60 (value type, FUN_143dd70a0); 0x18-byte
|
||||
// field data {u32 name hash, u16 offset @8, TypeInfo* @0x10} (FUN_143db7ac0).
|
||||
inline constexpr std::size_t type_super = 0x20, descriptor_field_count = 0x2a,
|
||||
class_fields = 0x38, value_fields = 0x60, field_size = 0x18, field_type = 0x10;
|
||||
// SystemSettings: every group inherits its "Platform" field; not a setting.
|
||||
inline constexpr std::uintptr_t system_settings_type = 0x7490460;
|
||||
inline constexpr std::array<Fingerprint, 6> named_settings_sites{{
|
||||
{named_settings_get, {0x48,0x83,0xec,0x48,0x44,0x88,0x4c,0x24,0x20,0x4d,0x8b,0xc8,0x4c,0x8b,0xc2,0x48,0x8d,0x54,0x24,0x30,0xe8,0x87,0xda,0xff,0xff,0x48,0x8b,0x00,0x48,0x83,0xc4,0x48}},
|
||||
{0x18d7080, {0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x48,0x81,0xec,0x60,0x01,0x00,0x00,0x48,0x8b,0x05,0x2b,0xd3,0x8e,0x05,0x48,0x33,0xc4,0x48,0x89,0x84,0x24,0x58,0x01,0x00,0x00}},
|
||||
{named_settings_set, {0x40,0x55,0x53,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x8d,0xac,0x24,0x48,0xff,0xff,0xff,0x48,0x81,0xec,0xb8,0x01,0x00,0x00,0x48,0x8b,0x05,0x51,0xa7,0x8e,0x05,0x48}},
|
||||
{0x18d5850, {0x4c,0x8b,0xdc,0x55,0x57,0x41,0x54,0x49,0x8d,0xab,0xc8,0xfb,0xff,0xff,0x48,0x81,0xec,0x20,0x05,0x00,0x00,0x48,0x8b,0x05,0x54,0xeb,0x8e,0x05,0x48,0x33,0xc4,0x48}},
|
||||
{0x3db5290, {0x48,0x8b,0x01,0x4d,0x8b,0xd0,0x44,0x0f,0xb7,0x40,0x04,0x41,0xc1,0xe8,0x05,0x41,0x83,0xe0,0x1f,0x41,0x83,0xf8,0x1c,0x0f,0x87,0xe8,0x00,0x00,0x00,0x49,0x63,0xc0}},
|
||||
{0x3daa880, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x49,0x8b,0x18,0x49,0x8b,0xf9,0x48,0x8b,0xcb,0x48,0x8b,0xf2,0xe8,0x7c,0x31,0x6c,0x01}},
|
||||
}};
|
||||
} // namespace dingosdk::game::build::v20260929::named_settings
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Remote skater sound.
|
||||
namespace dingosdk::game::build::v20260929::native_audio {
|
||||
// Skater sound component submit (handle, frame). Hooked to capture local sound.
|
||||
inline constexpr std::uintptr_t submit = 0x44a370;
|
||||
inline constexpr std::array<unsigned char, 17> submit_prefix{
|
||||
0x4c,0x8b,0xc9,0x4c,0x8b,0xc2,0x48,0x8b,0x0d,0xfb,0x95,0xd9,0x06,0x41,0x0f,0xb7,
|
||||
0x11};
|
||||
// Merges a partial sound frame into owned storage (dst, src, masks). Primitive fields only.
|
||||
inline constexpr std::uintptr_t frame_copy = 0x58d6540;
|
||||
inline constexpr std::array<unsigned char, 15> frame_copy_prefix{
|
||||
0x40,0x53,0x56,0x57,0x41,0xf6,0x00,0x01,0x4c,0x8d,0x91,0x64,0x01,0x00,0x00};
|
||||
// Native sound frame difference function (audited with frame_copy). Reference only.
|
||||
inline constexpr std::uintptr_t frame_difference = 0x58d53a0;
|
||||
// Resets a sound frame to its defaults.
|
||||
inline constexpr std::uintptr_t frame_reset = 0x58ced60;
|
||||
inline constexpr std::array<unsigned char, 14> frame_reset_prefix{
|
||||
0xc5,0xfc,0x10,0x05,0x68,0x49,0xb0,0x00,0x33,0xc0,0xc5,0xfc,0x11,0x01};
|
||||
// Component stop operation; clears its handle (component + 0x74).
|
||||
inline constexpr std::uintptr_t stop_sound = 0x43e540;
|
||||
inline constexpr std::array<unsigned char, 15> stop_sound_prefix{
|
||||
0x40,0x53,0x48,0x83,0xec,0x20,0x0f,0xb7,0x11,0x48,0x8b,0xd9,0x66,0x85,0xd2};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// Remote skater and skateboard cosmetics.
|
||||
namespace dingosdk::game::build::v20260929::native_cosmetics {
|
||||
// Customization component that owns the recipe at +0x150.
|
||||
inline constexpr std::uintptr_t cosmetic_component_vtable = 0x6084c88;
|
||||
// Deep-copies a recipe value into a component (component, recipe).
|
||||
inline constexpr std::uintptr_t recipe_copy = 0x5230b0;
|
||||
inline constexpr std::array<unsigned char, 24> recipe_copy_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x08,0x48,0x8b,
|
||||
0xf9,0x48,0x8b,0x98,0x90,0x00,0x00,0x00};
|
||||
// Finds a named entry in a loaded table by the FNV-1a hash of its name:
|
||||
// (out, name, table, flag, context) -> out, the fifth passed on the stack.
|
||||
// The skater loader's preset lookups reach it through a Lua binding that
|
||||
// passes a nil table on when a preset's bundle was not found, and the function
|
||||
// reads the table without checking it.
|
||||
inline constexpr std::uintptr_t named_lookup = 0x16fc900;
|
||||
inline constexpr std::array<unsigned char, 24> named_lookup_prefix{
|
||||
0x40,0x55,0x56,0x57,0x41,0x56,0x48,0x81,0xec,0xd8,0x02,0x00,0x00,0x48,0x8b,0x05,
|
||||
0xac,0x7a,0xac,0x05,0x48,0x33,0xc4,0x48};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::native_indicators {
|
||||
// Hooked HUD indicator functions.
|
||||
// HUD indicator presenter tick.
|
||||
inline constexpr std::uintptr_t hud_tick = 0x1aec120;
|
||||
inline constexpr std::array<unsigned char, 16> hud_tick_prefix{
|
||||
0x48, 0x8b, 0xc4, 0x48, 0x89, 0x48, 0x08, 0x41, 0x54, 0x48, 0x81, 0xec, 0xe0, 0x04, 0, 0};
|
||||
// Removes a provider from the indicator registry.
|
||||
inline constexpr std::uintptr_t provider_remove = 0x1aebd90;
|
||||
inline constexpr std::array<unsigned char, 16> provider_remove_prefix{
|
||||
0x48, 0x89, 0x54, 0x24, 0x10, 0x48, 0x89, 0x4c, 0x24, 0x08, 0x41, 0x54, 0x48, 0x83, 0xec, 0x70};
|
||||
// Creates an indicator widget through the presenter's widget cache.
|
||||
inline constexpr std::uintptr_t widget_create = 0x4379f80;
|
||||
inline constexpr std::array<unsigned char, 16> widget_create_prefix{
|
||||
0x48, 0x89, 0x5c, 0x24, 0x10, 0x48, 0x89, 0x6c, 0x24, 0x20, 0x44, 0x89, 0x44, 0x24, 0x18, 0x56};
|
||||
|
||||
// Called indicator functions.
|
||||
// Registers a provider with the indicator registry.
|
||||
inline constexpr std::uintptr_t indicator_register = 0x1ae76a0;
|
||||
inline constexpr std::array<unsigned char, 16> indicator_register_prefix{
|
||||
0x40, 0x53, 0x41, 0x57, 0x48, 0x83, 0xec, 0x38, 0x48, 0x8b, 0x02, 0x48, 0x8b, 0xd9, 0x48, 0x8b};
|
||||
// Presenter pending-removal pass.
|
||||
inline constexpr std::uintptr_t presenter_flush = 0x1ae7920;
|
||||
inline constexpr std::array<unsigned char, 16> presenter_flush_prefix{
|
||||
0x48, 0x8b, 0xc4, 0x48, 0x89, 0x48, 0x08, 0x41, 0x56, 0x48, 0x81, 0xec, 0x90, 0, 0, 0};
|
||||
// Initializes a widget's output property binding.
|
||||
inline constexpr std::uintptr_t property_bind = 0x2b7e930;
|
||||
inline constexpr std::array<unsigned char, 16> property_bind_prefix{
|
||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x60, 0x49};
|
||||
// Native class-reference getter: *(out) = *(storage) & ~4. The property
|
||||
// setters at 0x4651af0 / 0x4657040 pass class values as the referenced asset.
|
||||
inline constexpr std::uintptr_t class_reference_getter = 0x3dd1890;
|
||||
inline constexpr std::array<unsigned char, 11> class_reference_getter_prefix{
|
||||
0x48, 0x8b, 0x01, 0x48, 0x83, 0xe0, 0xfb, 0x48, 0x89, 0x02, 0xc3};
|
||||
|
||||
// Expected code of shared engine/model helpers this adapter calls.
|
||||
inline constexpr std::array<unsigned char, 8> set_customization_flag_prefix{0x48, 0x8b, 0x09, 0xe9, 0xb8, 0x1b, 0, 0};
|
||||
inline constexpr std::array<unsigned char, 16> model_record_prefix{
|
||||
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x56, 0x48, 0x83, 0xec, 0x30, 0x41, 0x0f, 0xb6, 0xf0, 0x48};
|
||||
inline constexpr std::array<unsigned char, 16> model_type_prefix{
|
||||
0x41, 0x0f, 0xb7, 0x40, 0x04, 0x41, 0xb8, 0x00, 0x06, 0x00, 0x00, 0x25, 0xff, 0x7f, 0x00, 0x00};
|
||||
inline constexpr std::array<unsigned char, 16> card_info_construct_prefix{
|
||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48};
|
||||
inline constexpr std::array<unsigned char, 16> native_text_release_prefix{
|
||||
0x48, 0x8b, 0x09, 0x48, 0x8d, 0x05, 0xaa, 0x86, 0xd7, 0x05, 0x48, 0x3b, 0xc8, 0x0f, 0x85, 0xbd};
|
||||
|
||||
// Globals and vtables
|
||||
// Indicator provider registry object.
|
||||
inline constexpr std::uintptr_t presenter_registry = 0x71267e0;
|
||||
inline constexpr std::uintptr_t hud_presenter_vtable = 0x629ea70;
|
||||
// Indicator template source.
|
||||
inline constexpr std::uintptr_t indicator_source_vtable = 0x629f0e0;
|
||||
// Parent of a created indicator widget.
|
||||
inline constexpr std::uintptr_t widget_parent_vtable = 0x6388c90;
|
||||
|
||||
// Type information
|
||||
// Compass icon asset property.
|
||||
inline constexpr std::uintptr_t compass_icon_type = 0x76e2850;
|
||||
inline constexpr std::uintptr_t vec3_type = 0x765d350;
|
||||
// UIPlayerInfo user-info field.
|
||||
inline constexpr std::uintptr_t user_info_type = 0x7258fb0;
|
||||
}
|
||||
Loaded 100 of 1051 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user