diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0f70e84 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..84b3a62 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc01b38 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bc90035 --- /dev/null +++ b/CMakeLists.txt @@ -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() + diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..fa4b9b9 --- /dev/null +++ b/CMakePresets.json @@ -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" + } + ] +} diff --git a/EmotePacker/main.cpp b/EmotePacker/main.cpp new file mode 100644 index 0000000..7b18bc5 --- /dev/null +++ b/EmotePacker/main.cpp @@ -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 [output folder] [--size ] [--max-frames ] +// [--max-width ] [--name ] +// +// Dropping a folder on the exe writes the pack next to it, in "_pack". +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 rgba; // straight alpha +}; +struct Frame { + Image image; + unsigned time{}; // ms + int x{}, y{}; // in the atlas +}; +struct Emote { + std::string name, file; + std::vector 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(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 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(w), static_cast(h)}; + image.rgba.resize(static_cast(w) * h * 4); + check(converter->CopyPixels(nullptr, w * 4, static_cast(image.rgba.size()), image.rgba.data()), "Reading pixels"); + return image; +} +std::optional metadata_number(IWICMetadataQueryReader *reader, const wchar_t *path) { + PROPVARIANT value; + PropVariantInit(&value); + std::optional 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 decode(const fs::path &file) { + ComPtr 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 frames; + if (container != GUID_ContainerFormatGif || count <= 1) { + ComPtr frame; + check(decoder->GetFrame(0, &frame), "Reading the picture"); + frames.push_back({to_rgba(frame.Get()), 0}); + return frames; + } + ComPtr global; + decoder->GetMetadataQueryReader(&global); + const int width = static_cast(metadata_number(global.Get(), L"/logscrdesc/Width").value_or(0)); + const int height = static_cast(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 canvas(static_cast(width) * height * 4, 0), saved; + struct Rect { int x{}, y{}, w{}, h{}; } previous; + unsigned previous_disposal{}; + for (UINT i = 0; i < count; ++i) { + ComPtr frame; + check(decoder->GetFrame(i, &frame), "Reading a GIF frame"); + ComPtr reader; + frame->GetMetadataQueryReader(&reader); + const auto piece = to_rgba(frame.Get()); + const Rect rect{static_cast(metadata_number(reader.Get(), L"/imgdesc/Left").value_or(0)), + static_cast(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(y) * width + x) * 4, 4, static_cast(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(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(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(std::lround(static_cast(image.width) * height / image.height))); + if (w > max_side) { + h = std::max(1, static_cast(std::lround(static_cast(h) * max_side / w))); + w = max_side; + } + if (w == image.width && h == image.height) return image; + ComPtr bitmap; + check(factory().CreateBitmapFromMemory(image.width, image.height, GUID_WICPixelFormat32bppRGBA, image.width * 4, + static_cast(image.rgba.size()), const_cast(image.rgba.data()), &bitmap), + "Loading pixels"); + ComPtr premultiplied; + check(factory().CreateFormatConverter(&premultiplied), "Creating a format converter"); + check(premultiplied->Initialize(bitmap.Get(), GUID_WICPixelFormat32bppPRGBA, WICBitmapDitherTypeNone, nullptr, 0, + WICBitmapPaletteTypeCustom), "Premultiplying"); + ComPtr 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 tidy(std::vector frames, std::size_t limit) { + std::vector 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 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(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(std::tolower(static_cast(c))); + return text; +} + +// Rows of frames across the atlas, tallest rows first. +int pack(std::vector &emotes, int width) { + std::vector 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 &rgba) { + // The PNG encoder takes BGRA everywhere. + std::vector bgra(rgba); + for (std::size_t i = 0; i < bgra.size(); i += 4) std::swap(bgra[i], bgra[i + 2]); + ComPtr stream; + check(factory().CreateStream(&stream), "Creating the output file"); + check(stream->InitializeFromFilename(file.c_str(), GENERIC_WRITE), "Opening the output file"); + ComPtr encoder; + check(factory().CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder), "Creating the PNG encoder"); + check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "Starting the PNG"); + ComPtr 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(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 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::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 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 emotes; + std::set 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 atlas(static_cast(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(y) * f.image.width * 4, f.image.width * 4, + atlas.begin() + (static_cast(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 [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: _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; +} diff --git a/Engine/Core/Console/command_registry.cpp b/Engine/Core/Console/command_registry.cpp new file mode 100644 index 0000000..6b22382 --- /dev/null +++ b/Engine/Core/Console/command_registry.cpp @@ -0,0 +1,113 @@ +#include "command_registry.h" +#include +namespace dingosdk::console { +std::vector completion_tokens(std::string_view input, int cursor) { + const int size = static_cast(input.size()); + const int limit = std::clamp(cursor, 0, size); + std::vector 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(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(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; + if constexpr (std::is_same_v) + return v; + else if constexpr (std::is_same_v) + return v ? "1" : "0"; + else { + std::array 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 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 = [&]() -> std::optional { + 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) + if (!std::isfinite(value)) + return std::nullopt; + return value; + }; + switch (type) { + case Type::integer: + return parse.operator()(); + case Type::unsigned_integer: + return parse.operator()(); + case Type::number: + return parse.operator()(); + default: + return std::nullopt; + } +} +} // namespace dingosdk::console diff --git a/Engine/Core/Console/command_registry.h b/Engine/Core/Console/command_registry.h new file mode 100644 index 0000000..33be1c1 --- /dev/null +++ b/Engine/Core/Console/command_registry.h @@ -0,0 +1,354 @@ +#pragma once + +#include "console_core.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +using Values = std::vector; +struct Output { + std::function write; + std::function clear; + std::function history; + void operator()(const std::string &text) const { + if (write) + write(text); + } +}; +struct State { + bool available = true; + std::optional 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 parse_value(Type type, std::string_view text); + +struct CompletionToken { + std::string text; + int begin = 0, end = 0; + bool quoted = false; +}; +std::vector 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 Registry { + public: + struct Argument { + std::string name; + Type type = Type::text; + bool optional = false, rest = false; + std::vector choices; + std::optional minimum, maximum; + std::function(const Context &, std::span)> 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 arguments; + std::vector aliases; + std::function inspect; + std::function run; + std::function 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 names{entry.name}; + names.insert(names.end(), entry.aliases.begin(), entry.aliases.end()); + std::map 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 &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 words) const { + const auto [entry, consumed] = resolve(words); + return !entry || entry->echo_input || consumed != words.size(); + } + bool local(std::span 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 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; + if constexpr (std::is_arithmetic_v) + return static_cast(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 complete(std::span words, const Context &context) const { + std::vector result; + std::set 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 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 resolve(std::span 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 entries_; + std::map lookup_; +}; +} // namespace dingosdk::console diff --git a/Engine/Core/Console/console_activity.cpp b/Engine/Core/Console/console_activity.cpp new file mode 100644 index 0000000..69246c3 --- /dev/null +++ b/Engine/Core/Console/console_activity.cpp @@ -0,0 +1,95 @@ +#include "console_activity.h" +#include + +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(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; +} +} diff --git a/Engine/Core/Console/console_activity.h b/Engine/Core/Console/console_activity.h new file mode 100644 index 0000000..a40f045 --- /dev/null +++ b/Engine/Core/Console/console_activity.h @@ -0,0 +1,29 @@ +#pragma once + +#include "console_core.h" +#include + +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 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 previous_; + bool preparation_reported_{}, waiting_reported_{}; + std::optional load_started_; +}; +} diff --git a/Engine/Core/Console/console_core.cpp b/Engine/Core/Console/console_core.cpp new file mode 100644 index 0000000..6ec43d3 --- /dev/null +++ b/Engine/Core/Console/console_core.cpp @@ -0,0 +1,214 @@ +#include "console_core.h" + +#include +#include +#include +#include +#include + +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('\t') || + (value >= static_cast(' ') && + value <= static_cast('~')); +} + +char ascii_lower(char value) noexcept { + if (value >= 'A' && value <= 'Z') { + return static_cast(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(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::chrono::duration_cast( + 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(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 ConsoleLogBuffer::snapshot_after( + std::uint64_t after_sequence) const { + std::lock_guard lock(mutex_); + std::vector 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::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 diff --git a/Engine/Core/Console/console_core.h b/Engine/Core/Console/console_core.h new file mode 100644 index 0000000..273a644 --- /dev/null +++ b/Engine/Core/Console/console_core.h @@ -0,0 +1,83 @@ +#pragma once + +#include "console_entry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 lines_; + std::size_t stored_bytes_{}; + std::uint64_t latest_sequence_{}; +}; + +} // namespace dingosdk diff --git a/Engine/Core/Console/console_entry.h b/Engine/Core/Console/console_entry.h new file mode 100644 index 0000000..792c168 --- /dev/null +++ b/Engine/Core/Console/console_entry.h @@ -0,0 +1,23 @@ +#pragma once + +#include "Engine/Core/Log/types.h" +#include +#include + +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); +} diff --git a/Engine/Core/Debug/Test/backtrace_tests.cpp b/Engine/Core/Debug/Test/backtrace_tests.cpp new file mode 100644 index 0000000..90f7f65 --- /dev/null +++ b/Engine/Core/Debug/Test/backtrace_tests.cpp @@ -0,0 +1,273 @@ +#include +#include +#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 +#include +#include +#include + +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(file), std::istreambuf_iterator()}; +} +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"})", "OK", 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(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> 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 executable{}; + GetModuleFileNameW(nullptr, executable.data(), static_cast(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(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(std::stoul(argv[3])), static_cast(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(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; } +} diff --git a/Engine/Core/Debug/backtrace.h b/Engine/Core/Debug/backtrace.h new file mode 100644 index 0000000..ad2df88 --- /dev/null +++ b/Engine/Core/Debug/backtrace.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include + +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; +} diff --git a/Engine/Core/Debug/client.cpp b/Engine/Core/Debug/client.cpp new file mode 100644 index 0000000..a9adeea --- /dev/null +++ b/Engine/Core/Debug/client.cpp @@ -0,0 +1,123 @@ +#include "backtrace.h" +#include "protocol.h" +#include "backtrace_config.h" +#include +#include +#include + +namespace dingosdk::backtrace { +namespace { +std::array 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 bool copy(std::array& 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(&handles), &module)) return StartResult::failed; + std::array module_path{}, process_path{}; + const auto module_size = GetModuleFileNameW(module, module_path.data(), static_cast(module_path.size())); + const auto process_size = GetModuleFileNameW(nullptr, process_path.data(), static_cast(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 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 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 storage(size); + auto* attributes = reinterpret_cast(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(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; +} +} diff --git a/Engine/Core/Debug/multipart.cpp b/Engine/Core/Debug/multipart.cpp new file mode 100644 index 0000000..3d17a3d --- /dev/null +++ b/Engine/Core/Debug/multipart.cpp @@ -0,0 +1,88 @@ +#include "multipart.h" +#include "upload.h" +#include +#include +#include + +namespace dingosdk::backtrace { +namespace { +bool stream_file(HANDLE file, DWORD bytes, const std::function& sink) { + std::array buffer{}; + while (bytes) { + DWORD read{}; + const auto wanted = std::min(bytes, static_cast(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(length.QuadPart) > maximum) return false; + bytes = static_cast(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(std::min(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(dump_bytes_) + attachments_.size() + log_bytes_ + suffix_.size(); + if (total > std::numeric_limits::max()) { error_ = ERROR_FILE_TOO_LARGE; return; } + size_ = static_cast(total); +} +bool MultipartReport::write(const std::function& sink) { + return !error_ && sink(prefix_.data(), static_cast(prefix_.size())) && stream_file(dump_.value, dump_bytes_, sink) && + sink(attachments_.data(), static_cast(attachments_.size())) && stream_file(log_.value, log_bytes_, sink) && + sink(suffix_.data(), static_cast(suffix_.size())); +} +} diff --git a/Engine/Core/Debug/multipart.h b/Engine/Core/Debug/multipart.h new file mode 100644 index 0000000..19fa1a0 --- /dev/null +++ b/Engine/Core/Debug/multipart.h @@ -0,0 +1,26 @@ +#pragma once +#include "protocol.h" +#include "Engine/Core/Json/json.h" +#include +#include + +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& sink); +private: + Handle dump_, log_; + DWORD dump_bytes_{}, log_bytes_{}, size_{}, error_{}; + std::string prefix_, attachments_, suffix_; +}; +} diff --git a/Engine/Core/Debug/native_dump.cpp b/Engine/Core/Debug/native_dump.cpp new file mode 100644 index 0000000..3bc5500 --- /dev/null +++ b/Engine/Core/Debug/native_dump.cpp @@ -0,0 +1,75 @@ +#include "native_dump.h" +#include "protocol.h" +#include "upload.h" +#include +#include +#include + +namespace dingosdk::backtrace { +namespace { +template bool read(std::span 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 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(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(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(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(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(seconds - epoch) : 0; +} +std::optional 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 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 {}; } +} +} diff --git a/Engine/Core/Debug/native_dump.h b/Engine/Core/Debug/native_dump.h new file mode 100644 index 0000000..fafe3cd --- /dev/null +++ b/Engine/Core/Debug/native_dump.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +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 find_native_dump(const std::filesystem::path& directory, + DWORD process_id, DWORD process_created) noexcept; +DWORD process_creation_seconds(HANDLE process) noexcept; +} diff --git a/Engine/Core/Debug/protocol.h b/Engine/Core/Debug/protocol.h new file mode 100644 index 0000000..9be0be6 --- /dev/null +++ b/Engine/Core/Debug/protocol.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +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 url{}; + std::array directory{}; + std::array application{}; + std::array 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 buffer{}; + const auto size = GetEnvironmentVariableW(name, buffer.data(), static_cast(buffer.size())); + return size && size < buffer.size() ? std::wstring(buffer.data(), size) : std::wstring{}; +} +} diff --git a/Engine/Core/Debug/upload.cpp b/Engine/Core/Debug/upload.cpp new file mode 100644 index 0000000..13f1917 --- /dev/null +++ b/Engine/Core/Debug/upload.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include "upload.h" +#include "protocol.h" +#include "multipart.h" +#include +#include +#include +#include +#include + +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(value.size()), nullptr, 0, nullptr, nullptr); + if (!size) throw std::runtime_error("Invalid text"); + std::string result(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), static_cast(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(-1); + if (!WinHttpCrackUrl(url.c_str(), static_cast(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 hash{}; + if (BCryptHash(BCRYPT_SHA256_ALG_HANDLE, nullptr, 0, + reinterpret_cast(const_cast(encoded.data())), static_cast(encoded.size()), + hash.data(), static_cast(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(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 buffer{}; + for (;;) { + DWORD read{}; + if (GetTickCount64() - started >= 60000 || !WinHttpReadData(request.value, buffer.data(), static_cast(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 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(size), '\0'); + if (!input.read(contents.data(), static_cast(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; } +} +} diff --git a/Engine/Core/Debug/upload.h b/Engine/Core/Debug/upload.h new file mode 100644 index 0000000..ad1adaa --- /dev/null +++ b/Engine/Core/Debug/upload.h @@ -0,0 +1,21 @@ +#pragma once +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include + +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; +// 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; +} diff --git a/Engine/Core/Hooks/hooks.cpp b/Engine/Core/Hooks/hooks.cpp new file mode 100644 index 0000000..aef3e25 --- /dev/null +++ b/Engine/Core/Hooks/hooks.cpp @@ -0,0 +1,280 @@ +#include "hooks.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 targets; + std::vector> 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(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(system.dwPageSize); + auto* allocation = static_cast(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 jump{std::byte{0xff}, std::byte{0x25}}; + const auto displacement = static_cast(page - jump.size()); + std::memcpy(jump.data() + 2, &displacement, sizeof(displacement)); + std::memcpy(allocation, jump.data(), jump.size()); + auto** pointer = reinterpret_cast(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 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(GetLastError())); + THREADENTRY32 entry{}; entry.dwSize = sizeof(entry); + LONG error = NO_ERROR; + if (!Thread32First(snapshot, &entry)) error = static_cast(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(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(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(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& 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 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->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(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 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 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; +} +} diff --git a/Engine/Core/Hooks/hooks.h b/Engine/Core/Hooks/hooks.h new file mode 100644 index 0000000..408bda1 --- /dev/null +++ b/Engine/Core/Hooks/hooks.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include + +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(); +} diff --git a/Engine/Core/Json/json.cpp b/Engine/Core/Json/json.cpp new file mode 100644 index 0000000..d772bff --- /dev/null +++ b/Engine/Core/Json/json.cpp @@ -0,0 +1,148 @@ +#include "json.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk { +Json::Json(std::initializer_list 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() == other.get(); + if (is_number_unsigned()) return std::cmp_equal(get(), other.get()); + return std::cmp_equal(get(), other.get()); +} +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() < other.get(); + if (is_number_unsigned()) { + if (other.is_number_unsigned()) return get() < other.get(); + return std::cmp_less(get(), other.get()); + } + if (other.is_number_unsigned()) return std::cmp_less(get(), other.get()); + return get() < other.get(); +} + +namespace { +struct ProfileHandler : rapidjson::BaseReaderHandler, ProfileHandler> { + struct Frame { Json* value; std::string key; }; + Json root; + std::vector 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 bool write_json(Writer& writer, const Json& value) { + if (value.is_null()) return writer.Null(); + if (value.is_boolean()) return writer.Bool(value.get()); + if (value.is_number_unsigned()) return writer.Uint64(value.get()); + if (value.is_number_integer()) return writer.Int64(value.get()); + if (value.is_number_float()) { + const auto number = value.get(); + 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(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(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(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::UTF8<>, + rapidjson::CrtAllocator, rapidjson::kWriteValidateEncodingFlag> writer(buffer); + ok = write_json(writer, *this); + } else { + rapidjson::PrettyWriter, rapidjson::UTF8<>, + rapidjson::CrtAllocator, rapidjson::kWriteValidateEncodingFlag> writer(buffer); + writer.SetIndent(' ', static_cast(indent)); + ok = write_json(writer, *this); + } + if (!ok) throw std::runtime_error("Cannot encode JSON string as UTF-8"); + return {buffer.GetString(), buffer.GetSize()}; +} +} diff --git a/Engine/Core/Json/json.h b/Engine/Core/Json/json.h new file mode 100644 index 0000000..cd270bd --- /dev/null +++ b/Engine/Core/Json/json.h @@ -0,0 +1,173 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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>; + using Array = std::vector; + + 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 requires (!std::same_as) + Json(T value) { + if constexpr (std::is_signed_v) data_ = static_cast(value); + else data_ = static_cast(value); + } + template Json(T value) : data_(static_cast(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 values); + template + requires (!std::convertible_to && !std::same_as) + Json(const R& values) : data_(Array{}) { + for (const auto& value : values) std::get(data_).emplace_back(value); + } + + static Json object() { Json value; value.data_ = Object{}; return value; } + static Json array(std::initializer_list values = {}) { + Json value; value.data_ = Array(values); return value; + } + static Json parse(std::string_view text, JsonLimits limits = {}); + template 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(data_); } + bool is_boolean() const { return std::holds_alternative(data_); } + bool is_number_unsigned() const { return std::holds_alternative(data_); } + bool is_number_integer() const { return is_number_unsigned() || std::holds_alternative(data_); } + bool is_number_float() const { return std::holds_alternative(data_); } + bool is_number() const { return is_number_integer() || is_number_float(); } + bool is_string() const { return std::holds_alternative(data_); } + bool is_array() const { return std::holds_alternative(data_); } + bool is_object() const { return std::holds_alternative(data_); } + std::size_t size() const { + if (is_object()) return std::get(data_).size(); + if (is_array()) return std::get(data_).size(); + return is_null() ? 0 : 1; + } + bool empty() const { return size() == 0; } + const std::string& string() const { return std::get(data_); } + template T get() const { + if constexpr (std::same_as) return *this; + else if constexpr (std::same_as) return string(); + else if constexpr (std::same_as) return std::get(data_); + else if constexpr (std::is_arithmetic_v) { + if (is_number_unsigned()) return static_cast(std::get(data_)); + if (std::holds_alternative(data_)) return static_cast(std::get(data_)); + if (is_number_float()) return static_cast(std::get(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(data_).contains(key); + } + const Json& at(std::string_view key) const { + const auto& fields = std::get(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(std::as_const(*this).at(key)); } + const Json& at(std::size_t index) const { return std::get(data_).at(index); } + Json& at(std::size_t index) { return std::get(data_).at(index); } + Json& operator[](std::string_view key) { + if (is_null()) data_ = Object{}; + return std::get(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 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() : fallback; + } + std::string value(std::string_view key, const char* fallback) const { + return value(key, std::string(fallback)); + } + Object& items() { return std::get(data_); } + const Object& items() const { return std::get(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(data_).push_back(std::move(value)); + } + + template class Iterator { + using Owner = std::conditional_t; + using Member = std::conditional_t; + 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; + using const_iterator = Iterator; + 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 data_; +}; +} diff --git a/Engine/Core/Log/banner.cpp b/Engine/Core/Log/banner.cpp new file mode 100644 index 0000000..2064ab1 --- /dev/null +++ b/Engine/Core/Log/banner.cpp @@ -0,0 +1,26 @@ +#include "logging_internal.h" +#include + +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 (...) {} +} +} diff --git a/Engine/Core/Log/crash.cpp b/Engine/Core/Log/crash.cpp new file mode 100644 index 0000000..3aeb6ae --- /dev/null +++ b/Engine/Core/Log/crash.cpp @@ -0,0 +1,61 @@ +#include "logging_internal.h" +#include "Engine/Core/Debug/backtrace.h" +#include + +namespace dingosdk::logging::detail { +namespace { +std::array 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(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(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(); +} +} diff --git a/Engine/Core/Log/events.cpp b/Engine/Core/Log/events.cpp new file mode 100644 index 0000000..f2b5fd5 --- /dev/null +++ b/Engine/Core/Log/events.cpp @@ -0,0 +1,86 @@ +#include "logging_internal.h" +#include +#include +#include +#include +#include + +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(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 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 next{}; + static std::atomic 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(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 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."); } +} +} diff --git a/Engine/Core/Log/logger.cpp b/Engine/Core/Log/logger.cpp new file mode 100644 index 0000000..ec8fceb --- /dev/null +++ b/Engine/Core/Log/logger.cpp @@ -0,0 +1,294 @@ +#include "logging_internal.h" +#include "Engine/Core/Debug/backtrace.h" +#include "Engine/Core/Console/console_core.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 { +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(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 { +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(formatted.size()), nullptr, 0); + if (count <= 0) return; + std::wstring wide(static_cast(count), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, formatted.data(), static_cast(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(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::info}; + std::atomic file_output{}; + bool initialized{}, console{}; + Options options; + std::vector repeats; + std::array, 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(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 sinks; + auto debugger = std::make_shared(); + sinks.push_back(debugger); + if (options.external_console) { + auto console = std::make_shared(); + s.console = console->available(); + if (s.console) sinks.push_back(console); + } + try { + std::filesystem::create_directories(options.directory); + sinks.push_back(std::make_shared(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(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(channel); + if (index >= channel_count) channel = Channel::runtime; + if (static_cast(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 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 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 (...) {} +} +} diff --git a/Engine/Core/Log/logging.h b/Engine/Core/Log/logging.h new file mode 100644 index 0000000..06de3df --- /dev/null +++ b/Engine/Core/Log/logging.h @@ -0,0 +1,64 @@ +#pragma once +#include "types.h" +#include "Engine/Core/Console/console_entry.h" +#include +#include +#include +#include +#include +#include +#include + +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 +void log(Level level, Context context, Channel channel, std::format_string 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)...)); + } catch (...) { write(Level::error, Channel::runtime, "Could not format a log message."); } + SetLastError(saved); +} +template +void log(Level level, Channel channel, std::format_string format, Args&&... args) noexcept { + log(level, default_context(channel), channel, format, std::forward(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 snapshot_after(std::uint64_t sequence); +std::uint64_t latest_sequence() noexcept; +} diff --git a/Engine/Core/Log/logging_internal.h b/Engine/Core/Log/logging_internal.h new file mode 100644 index 0000000..e20d6c9 --- /dev/null +++ b/Engine/Core/Log/logging_internal.h @@ -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; +} diff --git a/Engine/Core/Log/paths.cpp b/Engine/Core/Log/paths.cpp new file mode 100644 index 0000000..cf9e311 --- /dev/null +++ b/Engine/Core/Log/paths.cpp @@ -0,0 +1,83 @@ +#include "logging_internal.h" +#include +#include +#include +#include + +namespace dingosdk::logging { +namespace { +std::wstring environment(const wchar_t* name) { + std::array value{}; + const auto count = GetEnvironmentVariableW(name, value.data(), static_cast(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 module{}; + const auto count = GetModuleFileNameW(nullptr, module.data(), static_cast(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(text.size()), nullptr, 0, nullptr, nullptr); + std::string result(static_cast(size), '\0'); + if (size) WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(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(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(ch) : '?'; + } + if (text.size() > limit && result.size() == limit) { + while (!result.empty() && (static_cast(result.back()) & 0xc0) == 0x80) result.pop_back(); + if (!result.empty() && static_cast(result.back()) >= 0xc0) result.pop_back(); + result += "... [truncated]"; + } + return result; +} +} +} diff --git a/Engine/Core/Log/types.h b/Engine/Core/Log/types.h new file mode 100644 index 0000000..9ba2021 --- /dev/null +++ b/Engine/Core/Log/types.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include + +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(Context::count); +inline constexpr std::array 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(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(Channel::count); +inline constexpr std::array 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(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 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; +} +} diff --git a/Engine/Core/Platform/launcher_injection.cpp b/Engine/Core/Platform/launcher_injection.cpp new file mode 100644 index 0000000..225a716 --- /dev/null +++ b/Engine/Core/Platform/launcher_injection.cpp @@ -0,0 +1,477 @@ +#include "launcher_support.h" +#include "launcher_support_internal.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(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 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 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 image_file_bytes(const fs::path& file, std::uint32_t rva, std::size_t size, std::uintptr_t base) { + std::vector 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(length.QuadPart)); + DWORD read{}; + if (!ReadFile(handle, image.data(), static_cast(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(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(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 { + 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(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 bytes(image.begin() + static_cast(*window), + image.begin() + static_cast(*window + size + pad * 2)); + const auto delta = base - static_cast(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(block.VirtualAddress) + (value & 0xfff); + if (target < rva - pad || target + 8 > static_cast(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(bytes.begin() + pad, bytes.begin() + pad + static_cast(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(-1) && !thread.primary && + WaitForSingleObject(thread.handle, 0) == WAIT_OBJECT_0) + continue; + if (previous == static_cast(-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(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(image_base + static_cast(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(-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(-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(-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( + GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtSuspendThread")); + if (!suspend_thread) fail_windows("Cannot locate NtSuspendThread"); + constexpr LONG status_invalid_handle = static_cast(0xC0000008); + constexpr LONG status_thread_is_terminating = static_cast(0xC000004B); + std::vector 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(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(symbol); + + MEMORY_BASIC_INFORMATION local{}, remote{}; + if (!VirtualQuery(reinterpret_cast(address), &local, sizeof(local))) + fail_windows("Cannot inspect local LoadLibraryW"); + if (!VirtualQueryEx(process, reinterpret_cast(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(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(local.AllocationBase); + const auto remote_base = reinterpret_cast(remote.AllocationBase); + const auto dos = *reinterpret_cast(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( + local_base + static_cast(dos.e_lfanew)); + IMAGE_DOS_HEADER remote_dos{}; + SIZE_T read{}; + if (!ReadProcessMemory(process, reinterpret_cast(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(remote_base + static_cast(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(local.BaseAddress) + local.RegionSize; + const auto remote_region_end = reinterpret_cast(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 local_bytes{}, actual{}; + std::memcpy(local_bytes.data(), reinterpret_cast(address), local_bytes.size()); + if (!ReadProcessMemory(process, reinterpret_cast(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(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 diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp new file mode 100644 index 0000000..c85851c --- /dev/null +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -0,0 +1,232 @@ +#include "launcher_support.h" +#include "launcher_support_internal.h" + +#include +#include + +#include +#include +#include +#include +#include + +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 +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(length.QuadPart) > + static_cast(std::numeric_limits::max())) + fail("Invalid PE image size"); + size_ = static_cast(length.QuadPart); + mapping_.reset(CreateFileMappingW(file_.get(), nullptr, PAGE_READONLY, 0, 0, nullptr)); + if (!mapping_.get()) fail("Cannot map PE image"); + data_ = static_cast(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 sections; +}; + +ParsedPe parse_pe(const std::byte* data, std::size_t size) { + const auto dos = object_at(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(dos.e_lfanew); + const auto nt = object_at(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 sections; + sections.reserve(nt.FileHeader.NumberOfSections); + for (std::size_t index = 0; index < nt.FileHeader.NumberOfSections; ++index) + sections.push_back(object_at(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(rva) - start; + if (delta > section.SizeOfRawData || required > section.SizeOfRawData - delta) + fail("PE RVA has no file data"); + const auto offset = static_cast(section.PointerToRawData) + delta; + if (offset > file_size || required > file_size - offset) fail("PE RVA is outside the file"); + return static_cast(offset); + } + fail("PE RVA does not belong to a section"); +} + +template +T rva_object(const MappedFile& file, const ParsedPe& pe, std::uint32_t rva) { + return object_at(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(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(&object_size), sizeof(object_size), &result_size, 0) < 0 || + result_size != sizeof(object_size)) fail("Cannot query SHA-256 state size"); + std::vector 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 bytes(1024 * 1024); + for (;;) { + DWORD count{}; + if (!ReadFile(file.get(), bytes.data(), static_cast(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 digest{}; + if (BCryptFinishHash(hash.value, digest.data(), static_cast(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(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(file, pe, + exports.AddressOfNames + index * sizeof(std::uint32_t)); + if (rva_string(file, pe, name_rva) != export_name) continue; + const auto ordinal = rva_object(file, pe, + exports.AddressOfNameOrdinals + index * sizeof(std::uint16_t)); + if (ordinal >= exports.NumberOfFunctions) fail("Invalid PE export ordinal"); + const auto function = rva_object(file, pe, + exports.AddressOfFunctions + ordinal * sizeof(std::uint32_t)); + const std::uint64_t export_end = static_cast(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 diff --git a/Engine/Core/Platform/launcher_support.cpp b/Engine/Core/Platform/launcher_support.cpp new file mode 100644 index 0000000..ef2620d --- /dev/null +++ b/Engine/Core/Platform/launcher_support.cpp @@ -0,0 +1,223 @@ +#include "Engine/Core/Log/types.h" +#include "launcher_support.h" +#include "launcher_support_internal.h" + +#include + +#include +#include +#include +#include + +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 text{}; + const auto length = scan ? GetKeyNameTextW(static_cast(scan << 16), text.data(), static_cast(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(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(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(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& 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(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(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(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& 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 diff --git a/Engine/Core/Platform/launcher_support.h b/Engine/Core/Platform/launcher_support.h new file mode 100644 index 0000000..c36f3f3 --- /dev/null +++ b/Engine/Core/Platform/launcher_support.h @@ -0,0 +1,133 @@ +#pragma once + +#include + +#include "Engine/Game/Build/supported_build.h" + +#include +#include +#include +#include +#include + +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 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& 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& 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 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 diff --git a/Engine/Core/Platform/launcher_support_internal.h b/Engine/Core/Platform/launcher_support_internal.h new file mode 100644 index 0000000..fe0a5b3 --- /dev/null +++ b/Engine/Core/Platform/launcher_support_internal.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include + +// 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 diff --git a/Engine/Core/Platform/memory.h b/Engine/Core/Platform/memory.h new file mode 100644 index 0000000..edc53d7 --- /dev/null +++ b/Engine/Core/Platform/memory.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include + +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(address), destination, size, &count) && count == size; +} +template requires std::is_trivially_copyable_v +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(address), size); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +template requires std::is_trivially_copyable_v +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(address); + for (std::size_t i = 0; i < capacity; ++i) + if (!(destination[i] = source[i])) return static_cast(i); + return -1; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return -1; + } +} +} diff --git a/Engine/Core/Profiling/profiler.cpp b/Engine/Core/Profiling/profiler.cpp new file mode 100644 index 0000000..a58087b --- /dev/null +++ b/Engine/Core/Profiling/profiler.cpp @@ -0,0 +1,322 @@ +#include "profiler_internal.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::profiler { +std::atomic zones_enabled{false}; +namespace detail { +std::atomic client_thread{0}, present_thread{0}; +std::atomic sampling{false}; +} +namespace { +std::atomic sites{nullptr}; +std::atomic 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, history_size> values{}; + std::atomic 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 rings; + +// Totals since the monitor's last summary, taken with exchange(). +struct Totals { + std::atomic 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 latest_summary = std::make_shared(); + +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(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 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(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(*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(*c) : '?'); + LocalFree(description); + } + if (!result.empty()) return result; + using Query = LONG(NTAPI*)(HANDLE, int, PVOID, ULONG, PULONG); + static const auto query = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationThread")); + void* start{}; + if (query) query(handle, 9 /* ThreadQuerySetWin32StartAddress */, &start, sizeof(start), nullptr); + auto module = module_of(reinterpret_cast(start)); + std::string lower = module; + for (auto& c : lower) c = static_cast(std::tolower(static_cast(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(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(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(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(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(now - last).count()); + last = now; + const auto now_ticks = __rdtsc(); + const double ms_per_tick = seconds * 1e3 / static_cast(std::max(1, now_ticks - last_ticks)); + last_ticks = now_ticks; + auto next = std::make_shared(); + 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(ns) / 1e6; }; + const auto interval = client_interval.take(), frame = client_frame.take(), own = client_own.take(); + s.client_updates_per_second = static_cast(own.count) / seconds; + if (interval.count) { + s.client_interval_average_ms = ms(interval.sum) / static_cast(interval.count); + s.client_interval_longest_ms = ms(interval.longest); + } + if (frame.count) { + s.client_frame_average_ms = ms(frame.sum) / static_cast(frame.count); + s.client_frame_longest_ms = ms(frame.longest); + } + if (own.count) { + s.client_own_average_ms = ms(own.sum) / static_cast(own.count); + s.client_own_longest_ms = ms(own.longest); + } + const auto presents = present_interval.take(); + s.presents_per_second = static_cast(presents.count) / seconds; + if (presents.count) { + s.present_interval_average_ms = ms(presents.sum) / static_cast(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(site->ticks.exchange(0, std::memory_order_relaxed)) * ms_per_tick; + const auto longest = static_cast(site->longest.exchange(0, std::memory_order_relaxed)) * ms_per_tick; + if (!calls) continue; + ZoneRow row{site->name, static_cast(calls) / seconds, total / seconds, + total * 1e3 / static_cast(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(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(Series::client_interval)].push(static_cast(now - last) / 1e6f); + } + last = now; + client_own.add(own_ns); + rings[static_cast(Series::client_own)].push(static_cast(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(Series::client_frame)].push(static_cast(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(Series::present_interval)].push(static_cast(now - last) / 1e6f); + } + last = now; +} + +std::size_t history(Series series, std::span out) noexcept { + auto& ring = rings[static_cast(series)]; + const auto written = ring.written.load(std::memory_order_acquire); + const auto count = static_cast(std::min({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 summary() noexcept { + std::lock_guard lock(summary_mutex); + return latest_summary; +} +} // namespace dingosdk::profiler diff --git a/Engine/Core/Profiling/profiler.h b/Engine/Core/Profiling/profiler.h new file mode 100644 index 0000000..029738d --- /dev/null +++ b/Engine/Core/Profiling/profiler.h @@ -0,0 +1,147 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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 zones_enabled; +inline std::uint64_t now_ns() noexcept { + return static_cast(std::chrono::duration_cast( + 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 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 zones; // busiest first + std::vector threads; // busiest first +}; +std::shared_ptr 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 functions; // by self, then total + std::vector 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 sample_report() noexcept; + +// Engine function names for the sampler's report, by RVA (Engine/Game/Build//profiler.h, +// plus any " " lines in ReSkate.labels.tsv next to Skate.exe). +struct Label { std::uintptr_t rva; const char* name; }; +void set_engine_labels(std::span labels) noexcept; +} diff --git a/Engine/Core/Profiling/profiler_internal.h b/Engine/Core/Profiling/profiler_internal.h new file mode 100644 index 0000000..4e7f9dd --- /dev/null +++ b/Engine/Core/Profiling/profiler_internal.h @@ -0,0 +1,15 @@ +#pragma once +#include "profiler.h" +#include + +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 client_thread, present_thread; +// True while a sample runs; keeps the monitor (and so the zones) alive. +extern std::atomic 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); +} diff --git a/Engine/Core/Profiling/sampler.cpp b/Engine/Core/Profiling/sampler.cpp new file mode 100644 index 0000000..3d93b7d --- /dev/null +++ b/Engine/Core/Profiling/sampler.cpp @@ -0,0 +1,548 @@ +#include "profiler_internal.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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(c) : '?'); + return result; +} + +Modules snapshot_modules() { + Modules result; + std::vector handles(1024); + DWORD needed{}; + if (!EnumProcessModulesEx(GetCurrentProcess(), handles.data(), static_cast(handles.size() * sizeof(HMODULE)), + &needed, LIST_MODULES_ALL)) return result; + handles.resize(std::min(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(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(module.base); + const auto* nt = reinterpret_cast(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(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(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(context.Rsp); + context.Rsp += 8; + } else { + void* handler_data{}; + DWORD64 establisher{}; + RtlVirtualUnwind(UNW_FLAG_NHANDLER, module->base, pc, const_cast(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(module->base + entry->UnwindData); + if (!(info[0] >> 3 & UNW_FLAG_CHAININFO)) break; + const auto codes = info[2]; + entry = reinterpret_cast(info + 4 + ((codes + 1) & ~1) * 2); + } + return entry ? module->base + entry->BeginAddress : pc; +} + +// ---- names ---- +std::mutex labels_mutex; +std::vector