Merge ReSkate v2.1.0 into the trainer, in its new menu layout

The trainer keeps upstream's page and naming (SANDBOX; noclip, No Bail and
the boosts on Skater > Cheats; the full screen controller menu) with its own
TUNE sections, presets card and search inside. The MOVEMENT tab is gone:
`trainer open movement` opens Skater > Cheats, where the Revert Boost rows
now sit. Upstream's Hardcore preset sits beside Realistic; saving
trainer.json and reading the map file stay off the client thread.

Board Break's wear and the trainer no longer undo each other: wear writes
four tuning values itself, and every trainer write used to put them back to
the trainer's copy (and a level loaded on a worn board took them as stock).
board_break::handling_value() tells the trainer what wear wants; values the
player has not changed are handed back to it, their stock is the game's own.

Tests: Hardcore's check follows the sections (topic, not the old lists);
the session test pokes a flip speed past the trainer's own limit (100).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
andrewnakasandClaude Opus 5.5 committed 2026-10-18 00:13:49 -06:00
commit 0bcb0ba2ac
336 files changed
+25724 -1991

No files matched your search

+4
View File
@@ -29,3 +29,7 @@ analysis/raw/**/*.res
/analysis/
/docs/
/release
# Drone model sources (assets/models/fpv/CREDITS.md): large; only the prepared drone.glb is kept.
/assets/models/fpv/dji_avata2.glb
/assets/models/fpv/fpv_drone.glb
+96
View File
@@ -0,0 +1,96 @@
// Builds a built-in content pack (Engine/Vfs/builtin_packs.h) from its zip in assets/builtin:
// ReSkateBuiltinPacker <pack.zip> <pack.rskpack>
// The zip's files go into one plain archive (builtin_pack_format.h) with files of the same name
// next to each other (a Board Break deck pack carries much the same files once for every deck),
// which is then compressed whole with zstd and long-distance matching. Zip compresses each file
// on its own and finds none of that; this way each repeat costs next to nothing.
#include "Engine/Vfs/builtin_pack_format.h"
#include <miniz.h>
#include <zstd.h>
#include <algorithm>
#include <cstdio>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
namespace {
struct File {
std::string name;
std::vector<unsigned char> bytes;
};
std::string base_name(const std::string& path) {
const auto slash = path.find_last_of('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
template<class T> void put(std::vector<unsigned char>& out, T value) {
for (std::size_t i = 0; i < sizeof(T); ++i) out.push_back(static_cast<unsigned char>(value >> (8 * i)));
}
int fail(const std::string& message) {
std::cerr << "ReSkateBuiltinPacker: " << message << '\n';
return 1;
}
}
int main(int argc, char** argv) {
if (argc != 3) return fail("usage: ReSkateBuiltinPacker <pack.zip> <pack.rskpack>");
std::ifstream input(argv[1], std::ios::binary);
const std::vector<unsigned char> zip_bytes{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
if (zip_bytes.empty()) return fail(std::string("cannot read ") + argv[1]);
mz_zip_archive zip{};
if (!mz_zip_reader_init_mem(&zip, zip_bytes.data(), zip_bytes.size(), 0)) return fail("not a zip");
std::vector<File> files;
std::uint64_t total{};
for (mz_uint index = 0; index < mz_zip_reader_get_num_files(&zip); ++index) {
mz_zip_archive_file_stat info{};
if (!mz_zip_reader_file_stat(&zip, index, &info)) return fail("an unreadable entry");
if (info.m_is_directory) continue;
File file;
file.name = info.m_filename;
if (!dingosdk::mods::builtin_pack::safe_name(file.name)) return fail("an entry the game may not have: " + file.name);
file.bytes.resize(static_cast<std::size_t>(info.m_uncomp_size));
if (!file.bytes.empty() && !mz_zip_reader_extract_to_mem(&zip, index, file.bytes.data(), file.bytes.size(), 0))
return fail("cannot unpack " + file.name);
total += file.bytes.size();
files.push_back(std::move(file));
}
mz_zip_reader_end(&zip);
if (total > dingosdk::mods::builtin_pack::max_bytes) return fail("the pack is larger than a built-in pack may be");
// Same names together (a deck pack's decks/<deck>/<file> for every deck), then by path.
std::stable_sort(files.begin(), files.end(), [](const File& a, const File& b) {
const auto ka = base_name(a.name), kb = base_name(b.name);
return ka != kb ? ka < kb : a.name < b.name;
});
std::vector<unsigned char> plain;
plain.insert(plain.end(), dingosdk::mods::builtin_pack::magic.begin(), dingosdk::mods::builtin_pack::magic.end());
put<std::uint32_t>(plain, static_cast<std::uint32_t>(files.size()));
for (const auto& file : files) {
put<std::uint16_t>(plain, static_cast<std::uint16_t>(file.name.size()));
plain.insert(plain.end(), file.name.begin(), file.name.end());
put<std::uint64_t>(plain, file.bytes.size());
}
for (const auto& file : files) plain.insert(plain.end(), file.bytes.begin(), file.bytes.end());
ZSTD_CCtx* context = ZSTD_createCCtx();
if (!context) return fail("out of memory");
// A window to cover the whole pack (within what any decoder takes by default, 2^27), so a
// file repeated anywhere in it is found.
int window = 10;
while (window < dingosdk::mods::builtin_pack::max_window_log && (std::size_t{1} << window) < plain.size()) ++window;
ZSTD_CCtx_setParameter(context, ZSTD_c_compressionLevel, 19);
ZSTD_CCtx_setParameter(context, ZSTD_c_enableLongDistanceMatching, 1);
ZSTD_CCtx_setParameter(context, ZSTD_c_windowLog, window);
ZSTD_CCtx_setParameter(context, ZSTD_c_checksumFlag, 1);
std::vector<unsigned char> packed(ZSTD_compressBound(plain.size()));
const auto size = ZSTD_compress2(context, packed.data(), packed.size(), plain.data(), plain.size());
ZSTD_freeCCtx(context);
if (ZSTD_isError(size)) return fail(std::string("zstd: ") + ZSTD_getErrorName(size));
packed.resize(size);
std::ofstream output(argv[2], std::ios::binary | std::ios::trunc);
output.write(reinterpret_cast<const char*>(packed.data()), static_cast<std::streamsize>(packed.size()));
if (!output) return fail(std::string("cannot write ") + argv[2]);
std::cout << argv[1] << ": " << files.size() << " files, " << total << " bytes -> " << packed.size() << " bytes ("
<< zip_bytes.size() << " as a zip)\n";
return 0;
}
+58
View File
@@ -62,7 +62,21 @@ endif()
option(DINGOSDK_BUILD_MULTIPLAYER_TESTS "Build multiplayer protocol regression tests" OFF)
if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
enable_testing()
add_executable(dingosdk_map_fetch_policy_tests Extension/Multiplayer/Test/map_fetch_policy_tests.cpp)
target_include_directories(dingosdk_map_fetch_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME map_fetch_policy COMMAND dingosdk_map_fetch_policy_tests)
if(WIN32)
add_executable(dingosdk_https_download_timeout_tests Engine/Vfs/Test/https_download_timeout_tests.cpp)
target_include_directories(dingosdk_https_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_https_download_timeout_tests PRIVATE winhttp)
add_test(NAME https_download_timeout COMMAND dingosdk_https_download_timeout_tests
"${CMAKE_CURRENT_BINARY_DIR}/https-download-fixtures")
add_executable(dingosdk_map_download_timeout_tests Extension/Assets/Test/map_download_timeout_tests.cpp
Engine/Vfs/thunderstore_package.cpp)
target_include_directories(dingosdk_map_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_map_download_timeout_tests PRIVATE dingosdk_json)
add_test(NAME map_download_timeout COMMAND dingosdk_map_download_timeout_tests
"${CMAKE_CURRENT_BINARY_DIR}/map-download-fixtures")
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)
@@ -113,6 +127,24 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread)
endif()
add_test(NAME server_park_mods COMMAND dingosdk_server_park_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods")
# Tools locked for single players (lock, throwdown_locks), as each player's game reads its roster.
add_executable(dingosdk_server_lock_tests Server/Test/server_lock_tests.cpp Server/server_host.cpp Server/server_party.cpp
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp
$<$<BOOL:${WIN32}>:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp
Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp
Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp)
target_include_directories(dingosdk_server_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_include_directories(dingosdk_server_lock_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
if(WIN32)
target_link_libraries(dingosdk_server_lock_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm)
else()
target_link_libraries(dingosdk_server_lock_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread)
endif()
add_test(NAME server_locks COMMAND dingosdk_server_lock_tests)
add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp)
target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json)
@@ -165,6 +197,12 @@ endif()
if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
add_executable(dingosdk_velocity_boost_tests Engine/Game/Skater/Test/velocity_boost_tests.cpp)
add_test(NAME velocity_boost COMMAND dingosdk_velocity_boost_tests)
add_executable(dingosdk_drone_radio_tests Extension/Skater/Test/drone_radio_tests.cpp)
target_include_directories(dingosdk_drone_radio_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME drone_radio COMMAND dingosdk_drone_radio_tests)
add_executable(dingosdk_drone_flight_tests Extension/Skater/Test/drone_flight_tests.cpp)
target_include_directories(dingosdk_drone_flight_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME drone_flight COMMAND dingosdk_drone_flight_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}")
@@ -316,6 +354,11 @@ if(DINGOSDK_BUILD_TRAINER_TESTS)
add_executable(dingosdk_trainer_landing_tests Extension/Trainer/Test/landing_tests.cpp)
target_include_directories(dingosdk_trainer_landing_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trainer_landing COMMAND dingosdk_trainer_landing_tests)
# Built-in presets: Hardcore goes at least as far as Realistic, class names are spelt right, and
# the player's own presets never keep a built-in's name.
add_executable(dingosdk_trainer_presets_tests Extension/Trainer/Test/presets_tests.cpp Extension/Trainer/trainer_presets.cpp)
target_include_directories(dingosdk_trainer_presets_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trainer_presets COMMAND dingosdk_trainer_presets_tests)
add_executable(dingosdk_trainer_asset_list Extension/Trainer/Test/asset_list.cpp)
target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives)
@@ -336,6 +379,16 @@ if(DINGOSDK_BUILD_BOARD_BREAK_TESTS)
target_include_directories(dingosdk_board_break_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME board_break COMMAND dingosdk_board_break_tests)
endif()
option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF)
if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS)
enable_testing()
# The smoother curve: stock at 0, every key met, C2 at 1, and a framing segment the game's spline plays as
# meant; the saved settings read back as written.
add_executable(dingosdk_replay_keyframes_tests Extension/ReplayKeyframes/Test/replay_keyframes_tests.cpp
Extension/ReplayKeyframes/replay_curve.cpp Extension/ReplayKeyframes/replay_settings.cpp)
target_include_directories(dingosdk_replay_keyframes_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME replay_keyframes COMMAND dingosdk_replay_keyframes_tests)
endif()
option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF)
if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
enable_testing()
@@ -353,6 +406,11 @@ if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
endif()
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
foreach(target IN LISTS project_targets)
# (A build step, such as packing the built-in content, compiles nothing.)
get_target_property(target_kind ${target} TYPE)
if(target_kind STREQUAL "UTILITY")
continue()
endif()
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz|dingosdk_spotify_device)$")
dingosdk_configure_target(${target})
endif()
+20 -1
View File
@@ -139,6 +139,23 @@ template <class Context> class Registry {
const auto it = lookup_.find(lower(name));
return it == lookup_.end() ? nullptr : &entries_[it->second];
}
// The other entries whose name or an alias goes on from a name by more words:
// `perf hud` and `perf status` under `perf`.
std::vector<const Entry *> under(std::string_view name) const {
const auto prefix = std::string(name) + ' ';
const auto *self = find(name); // (`log` is also `log status`)
std::vector<const Entry *> result;
for (const auto &entry : entries_) {
if (&entry == self)
continue;
bool below = ascii_case_insensitive_starts_with(entry.name, prefix);
for (const auto &alias : entry.aliases)
below = below || ascii_case_insensitive_starts_with(alias, prefix);
if (below)
result.push_back(&entry);
}
return result;
}
static std::string usage(const Entry &entry) {
std::string result = entry.name;
for (const auto &arg : entry.arguments) {
@@ -259,7 +276,9 @@ template <class Context> class Registry {
values.push_back(std::move(*value));
}
if (index != args.size()) {
output("error: Too many arguments. Usage: " + usage(*entry));
// `perf hdu`: neither an argument of perf nor a command under it.
output("error: Too many arguments. Usage: " + usage(*entry) +
(under(entry->name).empty() ? "" : ". Use help " + entry->name + " for the commands under it."));
return false;
}
if (!entry->run) {
+145 -43
View File
@@ -3,15 +3,18 @@
#include "Engine/Core/Debug/crash_report.h"
#include "Engine/Core/Debug/native_dump.h"
#include "Engine/Core/Debug/upload.h"
#include "Engine/Core/Debug/multipart.h"
#include "Engine/Core/Debug/report_envelope.h"
#include "Engine/Core/Debug/report_details.h"
#include "Engine/Core/Debug/protocol.h"
#include "Engine/Core/Debug/trace.h"
#include "crash_report_config.h"
#include "Engine/Core/Log/logging.h"
#include <array>
#include <fstream>
#include <format>
#include <iostream>
#include <thread>
#include <vector>
using namespace dingosdk;
using namespace dingosdk::crash_report;
@@ -30,6 +33,19 @@ LONG WINAPI native_child_handler(EXCEPTION_POINTERS* pointers) {
CopyFileW(path.c_str(), (native_fixture_directory / L"duplicate.dmp").c_str(), TRUE);
ExitProcess(42);
}
// A heap block only the faulting stack points at: present in a dump only with the memory the
// stacks reference. Made at run time, so its bytes are nowhere else in the process.
// (Filled in place: a copy left on a stack would be in every dump.)
__declspec(noinline) void fill_marker(std::array<char, 64>& bytes) {
for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<char>((i * 37 + 11) ^ 0xa5);
}
// The game's own fatal handler: ReSkate's import hook asks for its dump first, then the game
// writes its own (game_fatal_dump.cpp).
LONG WINAPI fatal_child_handler(EXCEPTION_POINTERS* pointers) {
capture_game_fatal(GetCurrentThreadId(), pointers);
return native_child_handler(pointers);
}
enum class Mode { direct, native, fatal };
std::string read(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
@@ -49,6 +65,12 @@ void endpoint_checks() {
check(accepted_response(200, "c350c4ee5e2b4b779b2e5e4a2b0e1b9d", id), "Unhyphenated receipt rejected");
for (const auto* response : {"", "\n", "<html>OK</html>", R"({"detail":"ok"})", "c350c4ee-5e2b-4b77-9b2e", "c350c4ee-5e2b-4b77-9b2e-5e4a2b0e1b9d-00"})
check(!accepted_response(200, response, id), "Unconfirmed submission accepted");
check(accepted_response(200, R"({"id":"c350c4ee5e2b4b779b2e5e4a2b0e1b9d"})", id) && id == "c350c4ee5e2b4b779b2e5e4a2b0e1b9d",
"Envelope receipt rejected");
check(!accepted_response(200, R"({"id":"short"})", id) && !accepted_response(200, R"({"id":)", id), "Broken envelope receipt accepted");
check(envelope_endpoint(L"https://sentry.example.com/api/2/minidump/?sentry_key=0123abcd") ==
L"https://sentry.example.com/api/2/envelope/?sentry_key=0123abcd" && envelope_endpoint(L"https://example.com/post").empty(),
"Crash envelope endpoint not derived from the minidump endpoint");
check(!accepted_response(429, receipt, id), "Rate limited submission accepted");
check(!accepted_response(500, receipt, id), "Failed submission accepted");
}
@@ -96,54 +118,92 @@ void attachment_checks(const std::filesystem::path& root) {
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";
std::ofstream(event_details_path(dump), std::ios::binary)
<< R"({"breadcrumbs":{"values":[{"message":"last crash message"}]},"user":{"id":"0123456789abcdef0123456789abcdef"},)"
R"("contexts":{"reskate":{"mods":["A 1.0"]}},"tags":{"version":"replaced"},"release":"replaced"})";
const std::string event_id = "0123456789abcdef0123456789abcdef";
{
MultipartReport body(dump, attributes, "fixture-boundary");
ReportEnvelope body(dump, attributes, event_id);
check(body.error() == 0, "Cannot prepare attached report");
std::string wire;
check(body.write([&](const void* data, DWORD bytes) { wire.append(static_cast<const char*>(data), bytes); return true; }), "Cannot stream attached report");
check(wire.size() == body.size(), "Multipart Content-Length does not match transmitted bytes");
// Parse the emitted MIME parts independently to check names, boundaries and raw payloads.
std::map<std::string, std::pair<std::string, std::string>> parts;
const std::string delimiter = "--fixture-boundary";
std::size_t cursor{};
for (;;) {
check(wire.compare(cursor, delimiter.size(), delimiter) == 0, "Malformed MIME delimiter");
cursor += delimiter.size();
if (wire.compare(cursor, 4, "--\r\n") == 0) { check(cursor + 4 == wire.size(), "Trailing MIME data"); break; }
check(wire.compare(cursor, 2, "\r\n") == 0, "Missing MIME header separator"); cursor += 2;
const auto end = wire.find("\r\n\r\n", cursor), next = wire.find("\r\n" + delimiter, end + 4);
check(end != std::string::npos && next != std::string::npos, "Unterminated MIME part");
const auto header = wire.substr(cursor, end - cursor);
const auto name = header.find("name=\"");
check(name != std::string::npos, "Missing MIME field name");
const auto name_end = header.find('"', name + 6);
check(name_end != std::string::npos, "Invalid MIME field name");
check(parts.emplace(header.substr(name + 6, name_end - name - 6),
std::make_pair(header, wire.substr(end + 4, next - end - 4))).second, "Duplicate MIME field");
cursor = next + 2;
check(wire.size() == body.size(), "Envelope Content-Length does not match transmitted bytes");
// Read the envelope back independently: a header line, then each item's header line and
// exactly `length` bytes of payload, each followed by a newline.
std::size_t cursor = wire.find('\n');
check(cursor != std::string::npos, "Envelope has no header line");
const auto header = Json::parse(wire.substr(0, cursor));
check(header.at("event_id").string() == event_id, "Envelope header lacks the event ID");
++cursor;
std::vector<std::pair<Json, std::string>> items;
while (cursor < wire.size()) {
const auto line_end = wire.find('\n', cursor);
check(line_end != std::string::npos, "Unterminated item header");
auto item = Json::parse(wire.substr(cursor, line_end - cursor));
const auto length = item.at("length").get<std::size_t>();
check(line_end + 1 + length <= wire.size(), "Item longer than the envelope");
items.emplace_back(std::move(item), wire.substr(line_end + 1, length));
cursor = line_end + 1 + length;
check(cursor == wire.size() || wire[cursor] == '\n', "Item payload not followed by a newline");
if (cursor < wire.size()) ++cursor;
}
const auto event = Json::parse(parts.at("sentry").second);
check(parts.size() == 4 && event.at("tags").at("version").string() == "fixture-build" &&
event.at("release").string() == "reskate@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");
check(items.size() == 4, "Envelope does not hold the event, dump, report and log");
const auto& [event_item, event_payload] = items[0];
const auto event = Json::parse(event_payload);
check(event_item.at("type").string() == "event" && event.at("event_id").string() == event_id &&
event.at("platform").string() == "native" && event.at("level").string() == "fatal" &&
event.at("timestamp").get<double>() > 1.7e9, "Event item wrong");
check(event.at("tags").at("version").string() == "fixture-build" &&
event.at("release").string() == "reskate@fixture-build", "Attributes lost from the event");
check(event.at("breadcrumbs").at("values")[0].at("message").string() == "last crash message" &&
event.at("user").at("id").string() == "0123456789abcdef0123456789abcdef" &&
event.at("contexts").at("reskate").at("mods")[0].string() == "A 1.0", "Event details did not reach the event");
check(items[1].first.at("type").string() == "attachment" && items[1].first.at("attachment_type").string() == "event.minidump" &&
items[1].second == dump_bytes, "Dump not the event's minidump, or its bytes corrupted");
check(items[2].first.at("filename").string() == "report.json" &&
Json::parse(items[2].second).at("version").string() == "fixture-build", "Report details attachment missing");
check(items[3].first.at("filename").string() == "ReSkate.log" && items[3].second == original_log, "Log is not a named file attachment");
}
check(ReportEnvelope(dump, attributes, "not-an-id").error() != 0, "An envelope without a proper event ID");
{
MultipartReport body(dump, attributes, "fixture-boundary");
ReportEnvelope body(dump, attributes, event_id);
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");
check(ReportEnvelope(dump, attributes, event_id).error() != 0, "Missing queued log silently dropped");
attributes.erase("attachment.log");
check(MultipartReport(dump, attributes, "fixture-boundary").error() == 0, "Legacy report without logs blocked");
check(ReportEnvelope(dump, attributes, event_id).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 details_checks(const std::filesystem::path& root) {
const auto directory = root / L"details";
std::filesystem::create_directories(directory);
const auto log = directory / L"ReSkate.log";
std::ofstream(log, std::ios::binary)
<< "[2026-10-16 16:56:01.000] [1:2] SDK: [Runtime] [DEBUG] old debug line\r\n"
<< "[2026-10-16 16:56:02.000] [1:2] Native(C): [Level] [INFO] Level active [13]\r\n"
<< "[2026-10-16 16:56:03.000] [1:2] Native(M): [Graphics] [WARNING] Descriptor heap nearly full\r\n"
<< "[2026-10-16 16:56:04.000] [1:2] SDK: [Runtime] [DEBUG] recent debug line\r\n"
<< "a continuation without a time \xff\r\n";
const auto crumbs = log_breadcrumbs(log, 10, 2);
const auto& values = crumbs.at("values");
check(values.size() == 4, "Breadcrumbs kept a debug line from before the last few, or lost one");
check(values[0].at("message").string() == "Level active [13]" && values[0].at("level").string() == "info" &&
values[0].at("category").string() == "Native(C)/Level", "Breadcrumb text, level or category wrong");
check(values[1].at("level").string() == "warning" && values[2].at("level").string() == "debug", "Breadcrumb levels wrong");
check(values[3].at("message").string() == "a continuation without a time ?" &&
values[3].at("timestamp").get<double>() == values[2].at("timestamp").get<double>(), "A line without a time not kept with the one before");
check(values[1].at("timestamp").get<double>() - values[0].at("timestamp").get<double>() == 1.0 &&
values[0].at("timestamp").get<double>() > 1.7e9, "Breadcrumb times wrong");
check(log_breadcrumbs(directory / L"missing.log").is_null(), "Breadcrumbs from a missing log");
const auto id = install_id(directory);
check(id.size() == 32 && install_id(directory) == id, "Install ID not made once and kept");
check(crash_site(log).empty(), "A crash site read from something that is not a dump");
}
void queue_checks(const std::filesystem::path& root) {
const auto queue = root / L"queue";
std::filesystem::create_directories(queue);
@@ -165,6 +225,19 @@ void queue_checks(const std::filesystem::path& root) {
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");
const auto sent = queue / L"sent" / L"fixture.dmp";
check(std::filesystem::exists(sent) && read(log_attachment_path(sent)) == "original-session-log" &&
std::filesystem::exists(sent.wstring() + L".json"), "Confirmed report not kept in sent");
for (std::size_t i = 0; i < kept_sent_reports + 3; ++i) {
const auto path = queue / std::format(L"sent-{}.dmp", i);
std::ofstream(path) << "synthetic test data";
std::filesystem::last_write_time(path, std::filesystem::file_time_type::clock::now() + std::chrono::seconds(i + 1));
keep_sent_report(path);
}
std::size_t kept{};
for (const auto& entry : std::filesystem::directory_iterator(queue / L"sent")) kept += entry.path().extension() == L".dmp";
check(kept == kept_sent_reports && !std::filesystem::exists(sent) && std::filesystem::exists(queue / L"sent" / L"sent-12.dmp"),
"Sent reports not pruned to the newest");
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);
@@ -190,7 +263,8 @@ void queue_checks(const std::filesystem::path& root) {
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) {
void crash_check(const std::filesystem::path& root, Mode mode) {
const bool native = mode != Mode::direct;
std::filesystem::create_directories(root);
std::array<wchar_t, 32768> executable{};
GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()));
@@ -200,7 +274,8 @@ void crash_check(const std::filesystem::path& root, bool native) {
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());
auto command = std::format(L"\"{}\" {} \"{}\"", child_path.wstring(),
mode == Mode::fatal ? L"--fatal-crash" : 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");
@@ -226,10 +301,28 @@ void crash_check(const std::filesystem::path& root, bool native) {
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");
(mode == Mode::fatal ? "game-fatal-handler" : native ? "game-native" : "unhandled-exception"), "Wrong capture source");
if (mode != Mode::native) {
std::array<char, 64> marker{};
fill_marker(marker);
check(bytes.find(std::string_view(marker.data(), marker.size())) != std::string::npos,
"Dump lacks the heap memory the faulting stack points at");
}
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 tags = Json::parse(read(dump.wstring() + L".json"));
const auto site = tags.at("crash.site").string();
// (The fixture raises its exception, so it happens in RaiseException.)
check(site.starts_with("KERNELBASE.dll+0x") && site.size() > 17, "Crash site not tagged");
const auto details = Json::parse(read(event_details_path(dump)));
check(details.at("user").at("id").string().size() == 32 && details.at("breadcrumbs").at("values").size() > 0 &&
details.at("contexts").at("reskate").at("mods").is_array() && tags.at("mods.count").string() == "0",
"Crash did not carry its event details");
check(tags.at("game.map").string() == "dingolevel_reskate_lsc_b2" &&
tags.at("game.destination").string() == "Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint" &&
tags.at("gpu.name").string() == "Fixture GPU" && tags.at("gpu.driver").string() == "32.0.21025.1",
"Crash was not tagged with its level and graphics card");
if (mode == Mode::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);
@@ -284,8 +377,10 @@ int wmain(int argc, wchar_t** argv) {
check(result.accepted, "Sentry 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 (argc == 3 && (std::wstring_view(argv[1]) == L"--crash" || std::wstring_view(argv[1]) == L"--native-crash" ||
std::wstring_view(argv[1]) == L"--fatal-crash")) {
const bool fatal = std::wstring_view(argv[1]) == L"--fatal-crash";
const bool native = fatal || 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());
@@ -297,8 +392,13 @@ int wmain(int argc, wchar_t** argv) {
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);
if (native) SetUnhandledExceptionFilter(fatal ? fatal_child_handler : native_child_handler); // Reproduce the game's later handler registration.
set_destination(L"Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint");
set_gpu(L"Fixture GPU", L"32.0.21025.1");
auto* const volatile marker = new std::array<char, 64>;
fill_marker(*marker);
const ULONG_PTR arguments[]{reinterpret_cast<ULONG_PTR>(marker)};
RaiseException(test_exception, EXCEPTION_NONCONTINUABLE, 1, arguments);
return 3;
}
check(argc == 2, "Pass a fixture root");
@@ -312,9 +412,11 @@ int wmain(int argc, wchar_t** argv) {
check(start(root) == StartResult::failed, "Invalid endpoint enabled reporter");
endpoint_checks();
attachment_checks(root);
details_checks(root);
queue_checks(root);
crash_check(root / L"direct", false);
crash_check(root / L"native", true);
std::cout << "Timing envelopes, endpoint, receipts, multipart attachments, log snapshots, retries, retention, opt-out, exception chaining, direct capture and native-handler fallback checks passed.\n";
crash_check(root / L"direct", Mode::direct);
crash_check(root / L"native", Mode::native);
crash_check(root / L"fatal", Mode::fatal);
std::cout << "Timing envelopes, endpoint, receipts, multipart attachments, log snapshots, retries, retention, opt-out, exception chaining, direct capture, native-handler fallback and game fatal-handler capture checks passed.\n";
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
}
+26 -3
View File
@@ -22,6 +22,11 @@ template<std::size_t N> bool copy(std::array<wchar_t, N>& target, const std::wst
target[value.size()] = 0;
return true;
}
template<std::size_t N> void copy_cut(std::array<wchar_t, N>& target, std::wstring_view value) noexcept {
const auto size = std::min(value.size(), N - 1);
std::copy_n(value.begin(), size, target.begin());
target[size] = 0;
}
}
StartResult start(const std::filesystem::path& log_directory) noexcept {
try {
@@ -99,16 +104,34 @@ StartResult start(const std::filesystem::path& log_directory) noexcept {
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();
namespace {
void capture_on(DWORD thread_id, EXCEPTION_POINTERS* exception, CaptureSource source) noexcept {
if (!exception || !thread_id || InterlockedCompareExchange(&capturing, 1, 0) != 0) return;
report->thread_id = thread_id;
report->exception = exception;
report->source = source;
MemoryBarrier();
if (SetEvent(handles[requested])) {
const HANDLE wait_for[]{handles[captured], helper};
WaitForMultipleObjects(2, wait_for, FALSE, capture_timeout_ms);
}
}
}
void capture(EXCEPTION_POINTERS* exception) noexcept {
capture_on(GetCurrentThreadId(), exception, CaptureSource::unhandled_exception);
}
void capture_game_fatal(DWORD thread_id, EXCEPTION_POINTERS* exception) noexcept {
capture_on(thread_id, exception, CaptureSource::game_fatal_handler);
}
void set_destination(std::wstring_view destination) noexcept {
if (auto* const shared = report) copy_cut(shared->destination, destination);
}
void set_gpu(std::wstring_view name, std::wstring_view driver) noexcept {
if (auto* const shared = report) {
copy_cut(shared->gpu_name, name);
copy_cut(shared->gpu_driver, driver);
}
}
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.
+9
View File
@@ -1,6 +1,7 @@
#pragma once
#include <Windows.h>
#include <filesystem>
#include <string_view>
namespace dingosdk::crash_report {
enum class StartResult { disabled, ready, failed };
@@ -9,4 +10,12 @@ 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;
// The same, from the game's own fatal handler just before it writes its dump: thread_id is the
// thread that faulted, which need not be the calling one. The first capture of either kind wins.
void capture_game_fatal(DWORD thread_id, EXCEPTION_POINTERS* exception) noexcept;
// The level being loaded ("root + sublevel @ start point") and the graphics card the game renders
// on, for a report's tags: game.map, game.destination, gpu.name and gpu.driver. Nothing while
// reporting is off; too long a value is cut short.
void set_destination(std::wstring_view destination) noexcept;
void set_gpu(std::wstring_view name, std::wstring_view driver) noexcept;
}
+11 -1
View File
@@ -4,7 +4,10 @@
#include <string>
namespace dingosdk::crash_report {
inline constexpr DWORD protocol_version = 2;
inline constexpr DWORD protocol_version = 4;
// Who asked for the dump: ReSkate's last-chance filter, or the game's own fatal handler about to
// write its dump (which it does without ever reaching that filter).
enum class CaptureSource : DWORD { unhandled_exception, game_fatal_handler };
inline constexpr DWORD capture_timeout_ms = 15000;
struct SharedReport {
DWORD version{protocol_version};
@@ -14,6 +17,13 @@ struct SharedReport {
std::array<wchar_t, 32768> directory{};
std::array<wchar_t, 260> application{};
std::array<wchar_t, 32768> native_directory{};
// What the game was doing, for the report's tags: the level it loaded last, and the graphics
// card it renders on with its driver's version. Written by the game as they change, read by
// the helper after a crash (a torn read only spoils a tag).
std::array<wchar_t, 512> destination{};
std::array<wchar_t, 128> gpu_name{};
std::array<wchar_t, 32> gpu_driver{};
CaptureSource source{};
};
// 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 };
+230
View File
@@ -0,0 +1,230 @@
#include <Windows.h>
#include <bcrypt.h>
#include <DbgHelp.h>
#include "report_details.h"
#include "report_envelope.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <format>
#include <fstream>
#include <span>
#include <vector>
namespace dingosdk::crash_report {
namespace {
constexpr std::size_t max_breadcrumb_text = 600;
// How much of the log's end is read for breadcrumbs: far more than 100 lines ever take.
constexpr std::uintmax_t breadcrumb_window = 1024 * 1024;
// JSON text must be UTF-8: anything else in a line becomes '?'.
std::string valid_utf8(std::string_view text) {
std::string out;
out.reserve(text.size());
for (std::size_t i = 0; i < text.size();) {
const auto c = static_cast<unsigned char>(text[i]);
std::size_t length = c < 0x80 ? 1 : (c & 0xe0) == 0xc0 ? 2 : (c & 0xf0) == 0xe0 ? 3 : (c & 0xf8) == 0xf0 ? 4 : 0;
bool ok = length && i + length <= text.size() && !(length == 2 && c < 0xc2);
for (std::size_t k = 1; ok && k < length; ++k) ok = (static_cast<unsigned char>(text[i + k]) & 0xc0) == 0x80;
if (!ok) { out.push_back('?'); ++i; continue; }
if (length == 1 && c < 0x20 && c != '\t') out.push_back(' ');
else out.append(text.substr(i, length));
i += length;
}
return out;
}
// "[2026-10-16 16:56:20.354]" in local time -> seconds since 1970 (UTC), or a negative number.
double log_time(std::string_view stamp) {
int y{}, mo{}, d{}, h{}, mi{}, s{}, ms{};
if (stamp.size() < 25 || sscanf_s(std::string(stamp.substr(1, 23)).c_str(), "%d-%d-%d %d:%d:%d.%d", &y, &mo, &d, &h, &mi, &s, &ms) != 7)
return -1.0;
SYSTEMTIME local{static_cast<WORD>(y), static_cast<WORD>(mo), 0, static_cast<WORD>(d), static_cast<WORD>(h), static_cast<WORD>(mi),
static_cast<WORD>(s), static_cast<WORD>(ms)};
SYSTEMTIME utc{};
FILETIME file{};
if (!TzSpecificLocalTimeToSystemTime(nullptr, &local, &utc) || !SystemTimeToFileTime(&utc, &file)) return -1.0;
ULARGE_INTEGER ticks{};
ticks.LowPart = file.dwLowDateTime;
ticks.HighPart = file.dwHighDateTime;
return static_cast<double>(ticks.QuadPart) / 1e7 - 11644473600.0;
}
// One log line: "[time] [pid:tid] Source: [Channel] [LEVEL] text".
struct Line { double time{-1}; std::string category, level, text; bool debug{}; };
Line parse_line(std::string_view raw) {
Line line;
line.level = "info";
line.time = log_time(raw);
auto rest = raw;
if (line.time >= 0.0) {
if (const auto close = rest.find("] [", 1); close != std::string_view::npos) rest.remove_prefix(close + 2);
if (const auto close = rest.find("] "); close != std::string_view::npos) rest.remove_prefix(close + 2);
}
std::string source;
if (const auto colon = rest.find(": ["); colon != std::string_view::npos && colon < 24) {
source = rest.substr(0, colon);
rest.remove_prefix(colon + 2);
}
std::string channel;
if (rest.starts_with('[')) {
if (const auto close = rest.find("] "); close != std::string_view::npos && close < 40) {
channel = rest.substr(1, close - 1);
rest.remove_prefix(close + 2);
}
}
if (rest.starts_with('[')) {
if (const auto close = rest.find("] "); close != std::string_view::npos && close < 12) {
const auto level = rest.substr(1, close - 1);
rest.remove_prefix(close + 2);
line.debug = level == "DEBUG" || level == "TRACE";
line.level = level == "WARNING" ? "warning" : level == "ERROR" ? "error" : level == "FATAL" || level == "CRITICAL" ? "fatal"
: line.debug ? "debug" : "info";
}
}
line.category = source.empty() ? channel : channel.empty() ? source : source + "/" + channel;
line.text = valid_utf8(rest.substr(0, max_breadcrumb_text));
if (rest.size() > max_breadcrumb_text) line.text += "...";
line.category = valid_utf8(line.category);
return line;
}
}
std::filesystem::path event_details_path(const std::filesystem::path& dump) { return dump.wstring() + L".event.json"; }
Json log_breadcrumbs(const std::filesystem::path& log, std::size_t count, std::size_t recent) noexcept {
try {
std::ifstream input(log, std::ios::binary);
if (!input) return {};
input.seekg(0, std::ios::end);
const auto size = static_cast<std::uintmax_t>(input.tellg());
const auto start = size > breadcrumb_window ? size - breadcrumb_window : 0;
input.seekg(static_cast<std::streamoff>(start));
std::string text(static_cast<std::size_t>(size - start), '\0');
if (!input.read(text.data(), static_cast<std::streamsize>(text.size()))) return {};
std::vector<std::string_view> lines;
for (std::size_t at = 0; at < text.size();) {
auto end = text.find('\n', at);
if (end == std::string::npos) end = text.size();
auto line = std::string_view(text).substr(at, end - at);
if (line.ends_with('\r')) line.remove_suffix(1);
if (!line.empty()) lines.push_back(line);
at = end + 1;
}
if (start && !lines.empty()) lines.erase(lines.begin()); // (the first one is cut short)
std::vector<Line> kept;
for (std::size_t back = 0; back < lines.size() && kept.size() < count; ++back) {
auto line = parse_line(lines[lines.size() - 1 - back]);
if (line.debug && back >= recent) continue;
kept.push_back(std::move(line));
}
Json values = Json::array();
double last_time = -1.0;
for (auto it = kept.rbegin(); it != kept.rend(); ++it) {
Json crumb = Json::object();
// (A line without a time of its own, a continuation, takes the one before it.)
if (it->time >= 0.0) last_time = it->time;
if (last_time >= 0.0) crumb["timestamp"] = last_time;
if (!it->category.empty()) crumb["category"] = it->category;
crumb["level"] = it->level;
crumb["message"] = it->text;
values.push_back(std::move(crumb));
}
Json result = Json::object();
result["values"] = std::move(values);
return result;
} catch (...) { return {}; }
}
std::string crash_site(const std::filesystem::path& dump) noexcept {
try {
std::ifstream input(dump, std::ios::binary);
if (!input) return {};
std::vector<char> bytes((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
const std::span data(reinterpret_cast<const std::byte*>(bytes.data()), bytes.size());
const auto read = [&]<class T>(std::size_t offset, T& value) {
if (offset > data.size() || sizeof(T) > data.size() - offset) return false;
std::memcpy(&value, data.data() + offset, sizeof(T));
return true;
};
MINIDUMP_HEADER header{};
if (!read(std::size_t{0}, header) || header.Signature != MINIDUMP_SIGNATURE || header.NumberOfStreams > 256) return {};
MINIDUMP_EXCEPTION_STREAM exception{};
MINIDUMP_LOCATION_DESCRIPTOR modules{};
bool have_exception{}, have_modules{};
for (ULONG32 i = 0; i < header.NumberOfStreams; ++i) {
MINIDUMP_DIRECTORY entry{};
if (!read(static_cast<std::size_t>(header.StreamDirectoryRva) + i * sizeof(entry), entry)) return {};
if (entry.StreamType == ExceptionStream) have_exception = read(static_cast<std::size_t>(entry.Location.Rva), exception);
if (entry.StreamType == ModuleListStream) { modules = entry.Location; have_modules = true; }
}
if (!have_exception || !have_modules) return {};
const auto address = exception.ExceptionRecord.ExceptionAddress;
ULONG32 module_count{};
if (!read(static_cast<std::size_t>(modules.Rva), module_count) || module_count > 4096) return {};
for (ULONG32 i = 0; i < module_count; ++i) {
MINIDUMP_MODULE module{};
if (!read(static_cast<std::size_t>(modules.Rva) + sizeof(ULONG32) + i * sizeof(MINIDUMP_MODULE), module)) return {};
if (address < module.BaseOfImage || address - module.BaseOfImage >= module.SizeOfImage) continue;
ULONG32 name_bytes{};
if (!read(static_cast<std::size_t>(module.ModuleNameRva), name_bytes) || name_bytes > 2048 || name_bytes % 2) return {};
const auto name_at = static_cast<std::size_t>(module.ModuleNameRva) + sizeof(ULONG32);
if (name_at > data.size() || name_bytes > data.size() - name_at) return {};
std::wstring name(name_bytes / 2, L'\0');
std::memcpy(name.data(), data.data() + name_at, name_bytes);
const auto file = std::filesystem::path(name).filename().wstring();
std::string ascii;
for (const auto c : file) ascii.push_back(c < 0x80 && c >= 0x20 ? static_cast<char>(c) : '?');
return std::format("{}+{:#x}", ascii, address - module.BaseOfImage);
}
return {};
} catch (...) { return {}; }
}
std::string install_id(const std::filesystem::path& directory) noexcept {
try {
const auto path = directory / L"install-id.txt";
{
std::ifstream input(path, std::ios::binary);
std::string id;
if (input && std::getline(input, id) && id.size() == 32 && id.find_first_not_of("0123456789abcdef") == std::string::npos)
return id;
}
std::array<unsigned char, 16> random{};
if (!BCRYPT_SUCCESS(BCryptGenRandom(nullptr, random.data(), static_cast<ULONG>(random.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG)))
return {};
std::string id;
for (const auto byte : random) id += std::format("{:02x}", byte);
std::filesystem::create_directories(directory);
std::ofstream output(path, std::ios::binary | std::ios::trunc);
output << id << '\n';
return output ? id : std::string{};
} catch (...) { return {}; }
}
void remove_report_files(const std::filesystem::path& dump) noexcept {
std::error_code error;
std::filesystem::remove(dump, error);
if (error) return;
std::filesystem::remove(dump.wstring() + L".json", error);
std::filesystem::remove(log_attachment_path(dump), error);
std::filesystem::remove(event_details_path(dump), error);
}
void keep_sent_report(const std::filesystem::path& dump, std::size_t keep) noexcept {
try {
const auto sent = dump.parent_path() / L"sent";
std::error_code error;
std::filesystem::create_directories(sent, error);
// The dump last: a report in sent/ without it is pruned, never one in the queue twice.
for (const auto& file : {std::filesystem::path(dump.wstring() + L".json"), log_attachment_path(dump), event_details_path(dump), dump}) {
if (!std::filesystem::exists(file, error)) continue;
std::filesystem::rename(file, sent / file.filename(), error);
if (error) std::filesystem::remove(file, error);
}
std::vector<std::filesystem::directory_entry> kept;
for (const auto& entry : std::filesystem::directory_iterator(sent, error))
if (entry.is_regular_file() && entry.path().extension() == L".dmp") kept.push_back(entry);
std::sort(kept.begin(), kept.end(), [](const auto& a, const auto& b) { return a.last_write_time() > b.last_write_time(); });
for (std::size_t i = keep; i < kept.size(); ++i) remove_report_files(kept[i].path());
} catch (...) { remove_report_files(dump); }
}
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "Engine/Core/Json/json.h"
#include <cstddef>
#include <filesystem>
#include <string>
// What a crash report carries to Sentry besides its tags and attachments. Attachments (the log)
// are kept only where the server stores them; these go in the event itself, which it always does.
namespace dingosdk::crash_report {
// <dump>.event.json: the report's event fields other than tags and release (breadcrumbs,
// contexts, user), merged into the event when it is sent.
std::filesystem::path event_details_path(const std::filesystem::path& dump);
inline constexpr std::size_t max_event_details_bytes = 512 * 1024;
// The log's last lines as Sentry breadcrumbs ({"values": [...]}, oldest first): up to `count`
// lines, the debug ones only among the last `recent` (what happened just before is worth all of
// it; further back only what was said above debug). Each line's time, source, level and text.
Json log_breadcrumbs(const std::filesystem::path& log, std::size_t count = 100, std::size_t recent = 25) noexcept;
// Where the dump's exception happened, as "<module file name>+0x<offset>", or empty when the dump
// does not say (no exception, or an address outside every module).
std::string crash_site(const std::filesystem::path& dump) noexcept;
// An ID for this install, random and made once, kept in <directory>/install-id.txt: tells one
// player's crashes from another's without saying who they are. Empty when it cannot be kept.
std::string install_id(const std::filesystem::path& directory) noexcept;
// An accepted report's files go to <its folder>/sent, which keeps the newest `keep` reports: the
// player keeps their crashes after they are sent, whatever the server keeps.
inline constexpr std::size_t kept_sent_reports = 10;
void keep_sent_report(const std::filesystem::path& dump, std::size_t keep = kept_sent_reports) noexcept;
// Every file a queued report has (the dump, its tags, log and event details).
void remove_report_files(const std::filesystem::path& dump) noexcept;
}
@@ -1,7 +1,9 @@
#include "multipart.h"
#include "report_envelope.h"
#include "upload.h"
#include "report_details.h"
#include <algorithm>
#include <format>
#include <fstream>
#include <limits>
namespace dingosdk::crash_report {
@@ -23,6 +25,37 @@ bool file_length(HANDLE file, std::uintmax_t maximum, DWORD& bytes) {
bytes = static_cast<DWORD>(length.QuadPart);
return true;
}
// When the dump was written, as seconds since 1970: the crash's time, however late it is sent.
double written_at(HANDLE file) {
FILETIME created{}, accessed{}, written{};
if (!GetFileTime(file, &created, &accessed, &written)) return 0.0;
ULARGE_INTEGER ticks{};
ticks.LowPart = written.dwLowDateTime;
ticks.HighPart = written.dwHighDateTime;
return static_cast<double>(ticks.QuadPart) / 1e7 - 11644473600.0;
}
// An envelope item's header line: its type, length and what else Sentry reads of it.
std::string item_header(Json header, std::uint64_t length) {
header["length"] = length;
return header.dump() + "\n";
}
// The rest of the event the report made (report_details.h): the log's last lines, the mods, who and
// how long. A report queued without it, or with one that is unreadable, goes without.
void merge_details(const std::filesystem::path& dump, Json& event) {
const auto path = event_details_path(dump);
std::error_code missing;
const auto size = std::filesystem::file_size(path, missing);
if (missing || size > max_event_details_bytes) return;
std::ifstream input(path, std::ios::binary);
std::string text(static_cast<std::size_t>(size), '\0');
if (!input.read(text.data(), static_cast<std::streamsize>(text.size()))) return;
try {
const auto details = Json::parse(text, {max_event_details_bytes, 16, 65536});
if (!details.is_object()) return;
for (const auto& [key, value] : details.items())
if (key == "breadcrumbs" || key == "contexts" || key == "user" || key == "extra") event[key] = value;
} catch (...) {}
}
}
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 {
@@ -48,46 +81,74 @@ LogSnapshot snapshot_log(const std::filesystem::path& source, const std::filesys
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) {
ReportEnvelope::ReportEnvelope(const std::filesystem::path& dump, const Json& attributes, const std::string& event_id) {
if (!attributes.is_object() || event_id.size() != 32 || event_id.find_first_not_of("0123456789abcdef") != 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) {
!value.is_string() || value.string().size() > 4096) {
error_ = ERROR_INVALID_DATA; return;
}
}
// Sentry reads event attributes from the "sentry" field; attributes become searchable tags.
// The event: Sentry reads the crash from the minidump attached to it; the attributes are tags.
Json event = Json::object();
event["event_id"] = event_id;
event["platform"] = "native";
event["level"] = "fatal";
if (const auto time = written_at(dump_.value); time > 0.0) event["timestamp"] = time;
event["tags"] = attributes;
if (const auto version = attributes.value("version", ""); !version.empty()) event["release"] = "reskate@" + version;
const auto fields = event.dump();
if (fields.size() > 65536 || fields.find(boundary) != std::string::npos) { error_ = ERROR_INVALID_DATA; return; }
prefix_ = std::format("--{}\r\nContent-Disposition: form-data; name=\"sentry\"\r\n\r\n{}\r\n", boundary, fields);
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);
merge_details(dump, event);
const auto payload = event.dump();
if (payload.size() > max_event_details_bytes + 65536) { error_ = ERROR_INVALID_DATA; return; }
Json header = Json::object();
header["event_id"] = event_id;
prefix_ = header.dump() + "\n";
Json event_item = Json::object();
event_item["type"] = "event";
event_item["content_type"] = "application/json";
prefix_ += item_header(std::move(event_item), payload.size()) + payload + "\n";
Json minidump = Json::object();
minidump["type"] = "attachment";
minidump["attachment_type"] = "event.minidump";
minidump["filename"] = "crash.dmp";
prefix_ += item_header(std::move(minidump), dump_bytes_);
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);
Json report = Json::object();
report["type"] = "attachment";
report["filename"] = "report.json";
report["content_type"] = "application/json";
middle_ = "\n" + item_header(std::move(report), metadata.size()) + metadata + "\n";
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);
Json log = Json::object();
log["type"] = "attachment";
log["filename"] = "ReSkate.log";
log["content_type"] = "text/plain";
middle_ += item_header(std::move(log), log_bytes_);
suffix_ = "\n";
}
suffix_ = std::format("\r\n--{}--\r\n", boundary);
const auto total = prefix_.size() + static_cast<std::uint64_t>(dump_bytes_) + attachments_.size() + log_bytes_ + suffix_.size();
const auto total = prefix_.size() + static_cast<std::uint64_t>(dump_bytes_) + middle_.size() + log_bytes_ + suffix_.size();
if (total > std::numeric_limits<DWORD>::max()) { error_ = ERROR_FILE_TOO_LARGE; return; }
size_ = static_cast<DWORD>(total);
}
bool MultipartReport::write(const std::function<bool(const void*, DWORD)>& sink) {
bool ReportEnvelope::write(const std::function<bool(const void*, DWORD)>& sink) {
return !error_ && sink(prefix_.data(), static_cast<DWORD>(prefix_.size())) && stream_file(dump_.value, dump_bytes_, sink) &&
sink(attachments_.data(), static_cast<DWORD>(attachments_.size())) && stream_file(log_.value, log_bytes_, sink) &&
sink(suffix_.data(), static_cast<DWORD>(suffix_.size()));
sink(middle_.data(), static_cast<DWORD>(middle_.size())) && stream_file(log_.value, log_bytes_, sink) &&
(suffix_.empty() || sink(suffix_.data(), static_cast<DWORD>(suffix_.size())));
}
std::wstring envelope_endpoint(std::wstring_view minidump_url) {
constexpr std::wstring_view minidump = L"/minidump/";
const auto at = minidump_url.find(minidump);
if (at == std::wstring_view::npos) return {};
return std::wstring(minidump_url.substr(0, at)) + L"/envelope/" + std::wstring(minidump_url.substr(at + minidump.size()));
}
}
@@ -11,16 +11,24 @@ 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;
// A queued report as one Sentry envelope, as sentry-native sends a crash: the event (tags, release,
// time and the details report_details.h made), the minidump, report.json and the log. Sentry's
// minidump endpoint streams attachments other than the dump to an object store, which a
// self-hosted Sentry without one drops ("upload_failed"); an envelope's are kept.
// Keeps the queued files open and unchanged until the complete request is sent.
class MultipartReport {
class ReportEnvelope {
public:
MultipartReport(const std::filesystem::path& dump, const Json& attributes, const std::string& boundary);
// event_id: 32 lowercase hexadecimal digits.
ReportEnvelope(const std::filesystem::path& dump, const Json& attributes, const std::string& event_id);
DWORD error() const { return error_; }
DWORD size() const { return size_; }
bool write(const std::function<bool(const void*, DWORD)>& sink);
private:
Handle dump_, log_;
DWORD dump_bytes_{}, log_bytes_{}, size_{}, error_{};
std::string prefix_, attachments_, suffix_;
std::string prefix_, middle_, suffix_;
};
// The envelope endpoint of a Sentry minidump endpoint URL (".../api/<project>/minidump/?sentry_key=..."
// -> ".../api/<project>/envelope/?sentry_key=..."), or empty when it is not one.
std::wstring envelope_endpoint(std::wstring_view minidump_url);
}
+7 -6
View File
@@ -78,7 +78,7 @@ void configure(State& s) {
}
// Set by start() and never destroyed: the worker thread may outlive static
// destruction, and a DLL must not join a thread while the loader lock is held.
std::atomic<State*> active{};
std::atomic<State*> started_state{};
constexpr std::uint64_t hitch_ns = 50ull * 1000 * 1000;
struct Pacing {
std::atomic<std::uint64_t> last{}, frames{}, total{}, hitches{}, worst{};
@@ -165,17 +165,18 @@ std::string envelope(const Transaction& transaction) {
}
bool start() noexcept {
try {
if (active.load()) return true;
if (started_state.load()) return true;
auto s = std::make_unique<State>();
configure(*s);
if (!s->enabled) return false;
State* expected{};
if (active.compare_exchange_strong(expected, s.get())) (void)s.release();
if (started_state.compare_exchange_strong(expected, s.get())) (void)s.release();
return true;
} catch (...) { return false; }
}
bool active() noexcept { return started_state.load(std::memory_order_relaxed) != nullptr; }
void frame(std::uint64_t now_ns) noexcept {
if (!active.load(std::memory_order_relaxed)) return;
if (!started_state.load(std::memory_order_relaxed)) return;
const auto previous = pacing.last.exchange(now_ns, std::memory_order_relaxed);
if (!previous || now_ns <= previous) return;
const auto took = now_ns - previous;
@@ -191,7 +192,7 @@ FramePacing take_frame_pacing() noexcept {
void record(std::string_view name, std::string_view operation, std::chrono::milliseconds duration,
bool ok, Tags tags, Measurements measurements) noexcept {
try {
auto* const started = active.load();
auto* const started = started_state.load();
if (!started) return;
auto& s = *started;
Transaction transaction;
@@ -218,7 +219,7 @@ void record(std::string_view name, std::string_view operation, std::chrono::mill
}
void flush(std::chrono::milliseconds timeout) noexcept {
try {
auto* const started = active.load();
auto* const started = started_state.load();
if (!started) return;
auto& s = *started;
std::unique_lock lock(s.mutex);
+2
View File
@@ -18,6 +18,8 @@ using Measurements = std::vector<Measurement>;
// unless RESKATE_TRACING=1 (the launcher's "Send performance timings" setting),
// and off whenever crash reporting is (RESKATE_CRASH_REPORTING=0).
bool start() noexcept;
// Whether this launch sends timings (start() succeeded).
bool active() noexcept;
// Queues one finished operation as a Sentry transaction. Never waits on the
// network and never throws. A launch's launcher and game share one trace.
void record(std::string_view name, std::string_view operation, std::chrono::milliseconds duration,
+24 -16
View File
@@ -3,7 +3,8 @@
#include <bcrypt.h>
#include "upload.h"
#include "protocol.h"
#include "multipart.h"
#include "report_envelope.h"
#include "report_details.h"
#include <algorithm>
#include <chrono>
#include <fstream>
@@ -23,14 +24,7 @@ std::string utf8(std::wstring_view value) {
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
void remove_report(const std::filesystem::path& dump) {
std::error_code error;
std::filesystem::remove(dump, error);
if (!error) {
std::filesystem::remove(dump.wstring() + L".json", error);
std::filesystem::remove(log_attachment_path(dump), error);
}
}
void remove_report(const std::filesystem::path& dump) { remove_report_files(dump); }
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';
@@ -54,10 +48,16 @@ bool parse_endpoint(const std::wstring& url, Endpoint& endpoint) {
bool accepted_response(DWORD status, std::string_view response, std::string& report_id) {
report_id.clear();
if (status < 200 || status >= 300) return false;
// Sentry's minidump endpoint answers with the event ID as plain text.
// Sentry's envelope endpoint answers {"id":"<event ID>"}; its minidump endpoint the ID as plain text.
const auto first = response.find_first_not_of(" \t\r\n"), last = response.find_last_not_of(" \t\r\n");
if (first == std::string_view::npos) return false;
const auto id = response.substr(first, last - first + 1);
auto id = std::string(response.substr(first, last - first + 1));
if (id.starts_with('{')) {
try {
const auto receipt = Json::parse(id, {4096, 4, 64});
id = receipt.is_object() && receipt.contains("id") && receipt.at("id").is_string() ? receipt.at("id").string() : std::string{};
} catch (...) { return false; }
}
if (id.size() > 36 || id.find_first_not_of("0123456789abcdefABCDEF-") != std::string_view::npos ||
std::count_if(id.begin(), id.end(), [](char c) { return c != '-'; }) != 32) return false;
report_id = id;
@@ -78,8 +78,17 @@ UploadResult upload_report(const std::wstring& url, const std::filesystem::path&
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);
// Sent as an envelope (report_envelope.h), to the envelope endpoint beside the minidump one.
Endpoint envelope;
if (!parse_endpoint(envelope_endpoint(url), envelope)) { result.error = ERROR_INVALID_PARAMETER; return result; }
endpoint = envelope;
std::array<unsigned char, 16> random{};
if (!BCRYPT_SUCCESS(BCryptGenRandom(nullptr, random.data(), static_cast<ULONG>(random.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG))) {
result.error = ERROR_INVALID_DATA; return result;
}
std::string event_id;
for (const auto byte : random) event_id += std::format("{:02x}", byte);
ReportEnvelope body(dump, attributes, event_id);
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; }
@@ -94,8 +103,7 @@ UploadResult upload_report(const std::wstring& url, const std::filesystem::path&
!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";
const std::wstring headers = L"Content-Type: application/x-sentry-envelope\r\n";
if (!WinHttpSendRequest(request.value, headers.c_str(), static_cast<DWORD>(headers.size()), nullptr, 0, body.size(), 0)) {
result.error = GetLastError(); return result;
}
@@ -156,7 +164,7 @@ std::size_t upload_pending(const std::wstring& url, const std::filesystem::path&
break;
}
status_file(directory, "Accepted by Sentry: " + result.report_id);
remove_report(entry.path());
keep_sent_report(entry.path());
if (++uploaded == 4) break; // Bound work for a single launch.
}
return uploaded;
+14
View File
@@ -8,6 +8,7 @@
#include <map>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
namespace dingosdk {
@@ -193,14 +194,27 @@ HookStatus WINAPI hook_prepare(void* target, void* replacement, void** original)
return HookOk;
});
}
namespace {
thread_local unsigned batch_depth{}, batch_queued{};
}
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;
if (batch_depth) {
hook->queued = true;
++batch_queued;
return HookOk;
}
return transact({{hook, true}});
});
}
HookBatch::HookBatch() noexcept { ++batch_depth; }
HookBatch::~HookBatch() {
if (--batch_depth || !std::exchange(batch_queued, 0)) return;
(void)hook_apply_queued();
}
HookStatus WINAPI hook_disable(void* target) {
return locked([&](Registry& r) {
if (!r.initialized) return HookNotInitialized;
+13
View File
@@ -30,6 +30,19 @@ HookStatus WINAPI hook_disable(void* target);
HookStatus WINAPI hook_remove(void* target);
HookStatus WINAPI hook_queue_enable(void* target);
HookStatus WINAPI hook_apply_queued();
// While one is alive on a thread, hook_enable on that thread only queues the hook (and answers
// HookOk); the outermost one, as it ends, attaches all of them in one transaction. Each
// transaction lists every thread on the system and suspends every one of the game's, a few
// milliseconds each, so installs made while the game runs are grouped: on the client thread,
// a frame paid for each of them (2026-10-15). A hook that then fails to attach stays detached,
// as a failed hook_enable leaves it.
class HookBatch {
public:
HookBatch() noexcept;
~HookBatch();
HookBatch(const HookBatch&) = delete;
HookBatch& operator=(const HookBatch&) = delete;
};
HookStatus WINAPI hook_shutdown();
const char* WINAPI hook_status_string(HookStatus status);
// Captured on the calling thread; formatting/logging happens only after Detours
+33 -1
View File
@@ -3,6 +3,8 @@
#include "path_text.h"
#ifdef _WIN32
#include <mutex>
#include <map>
#include <Windows.h>
#include <bcrypt.h>
#else
@@ -304,7 +306,8 @@ void validate_game_file(const fs::path& path) {
detail::fail("Skate.exe SHA-256 does not match the supported build");
}
void validate_steam_api_file(const fs::path& path) {
namespace {
void check_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)
@@ -317,6 +320,35 @@ void validate_steam_api_file(const fs::path& path) {
if (sha256_file(path) != expected_steam_api_sha256)
detail::fail("steam_api64.dll is not the supported original Steam library (SHA-256 mismatch)");
}
} // namespace
// Checked once a file for the process: the file is read and hashed, and the game's Steam
// features each ask on first use, some again every few seconds while Steam is not ready, on the
// client thread (2026-10-15). What it found, either way, is the answer after that.
void validate_steam_api_file(const fs::path& path) {
static std::mutex mutex;
static std::map<std::wstring, std::string> checked; // path -> the failure, empty when it passed
const auto key = path.wstring();
{
std::lock_guard lock(mutex);
if (const auto found = checked.find(key); found != checked.end()) {
if (!found->second.empty()) detail::fail(found->second.c_str());
return;
}
}
std::string failure;
try {
check_steam_api_file(path);
} catch (const std::exception& error) {
failure = error.what();
if (failure.empty()) failure = "steam_api64.dll could not be checked";
}
{
std::lock_guard lock(mutex);
checked.emplace(key, failure);
}
if (!failure.empty()) detail::fail(failure.c_str());
}
#else // _WIN32
+27
View File
@@ -33,6 +33,33 @@ inline bool peek_bytes(std::uintptr_t address, void* destination, std::size_t si
template<class T> requires std::is_trivially_copyable_v<T>
bool peek(std::uintptr_t address, T& value) noexcept { return peek_bytes(address, &value, sizeof(value)); }
// Fresh read/write memory within reach of a rel32 jump from an image at base: the first free
// spot past its end, for a code stub that a patched instruction jumps to. Null when nothing is
// free within 1.75 GB of the image.
inline void* allocate_near_image(std::uintptr_t base, std::size_t image_size, std::size_t bytes) noexcept {
SYSTEM_INFO information{};
GetSystemInfo(&information);
const auto granularity = static_cast<std::uintptr_t>(information.dwAllocationGranularity);
const auto align_up = [granularity](std::uintptr_t value) { return (value + granularity - 1) & ~(granularity - 1); };
auto cursor = align_up(base + image_size);
const auto limit = base + 0x70000000ULL;
while (cursor < limit) {
MEMORY_BASIC_INFORMATION region{};
if (!VirtualQuery(reinterpret_cast<const void*>(cursor), &region, sizeof(region))) break;
const auto region_base = reinterpret_cast<std::uintptr_t>(region.BaseAddress);
const auto region_end = region_base + region.RegionSize;
if (region.State == MEM_FREE) {
const auto candidate = align_up(cursor > region_base ? cursor : region_base);
if (candidate <= region_end && region_end - candidate >= bytes)
if (auto* const result = VirtualAlloc(reinterpret_cast<void*>(candidate), bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE))
return result;
}
if (region_end <= cursor) break;
cursor = align_up(region_end);
}
return nullptr;
}
// 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.
+57 -1
View File
@@ -13,10 +13,15 @@ std::atomic<bool> zones_enabled{false};
namespace detail {
std::atomic<std::uint32_t> client_thread{0}, present_thread{0};
std::atomic<bool> sampling{false};
std::atomic<bool> marking_presents{false};
std::atomic<std::uint32_t> present_mark_count{0};
std::array<std::atomic<std::uint64_t>, present_mark_capacity> present_marks{};
}
namespace {
std::atomic<Site*> sites{nullptr};
std::atomic<bool> hud_visible{false}, window_visible{false}, monitor_running{false};
std::atomic<bool> zones_kept{false};
std::atomic<std::uint64_t> own_latest{0}, present_count{0};
// 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.
@@ -241,7 +246,7 @@ void monitor() {
std::lock_guard lock(summary_mutex);
latest_summary = std::move(next);
}
zones_enabled.store(false, std::memory_order_relaxed);
zones_enabled.store(zones_kept.load(std::memory_order_relaxed), 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();
@@ -270,6 +275,7 @@ Site::Site(const char* site_name) noexcept : name(site_name) {
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);
own_latest.store(own_ns, std::memory_order_relaxed);
if (!zones_enabled.load(std::memory_order_relaxed)) return;
static std::uint64_t last{};
const auto now = now_ns();
@@ -290,6 +296,11 @@ void record_client_frame(std::uint64_t frame_ns) noexcept {
void record_present() noexcept {
detail::present_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
present_count.fetch_add(1, std::memory_order_relaxed);
if (detail::marking_presents.load(std::memory_order_relaxed)) {
const auto at = detail::present_mark_count.fetch_add(1, std::memory_order_relaxed);
if (at < detail::present_mark_capacity) detail::present_marks[at].store(now_ns(), std::memory_order_relaxed);
}
if (!zones_enabled.load(std::memory_order_relaxed)) return;
static std::uint64_t last{};
const auto now = now_ns();
@@ -315,6 +326,51 @@ void set_window(bool visible) noexcept { window_visible.store(visible, std::memo
bool window() noexcept { return window_visible.load(std::memory_order_acquire); }
bool active() noexcept { return wanted(); }
void keep_zones(bool on) noexcept {
zones_kept.store(on, std::memory_order_relaxed);
if (on) zones_enabled.store(true, std::memory_order_relaxed);
else if (!monitor_running.load(std::memory_order_acquire)) zones_enabled.store(false, std::memory_order_relaxed);
}
std::uint64_t last_client_own_ns() noexcept { return own_latest.load(std::memory_order_relaxed); }
std::uint64_t presents() noexcept { return present_count.load(std::memory_order_relaxed); }
void mark_zones(ZoneMark& mark) noexcept {
try {
mark.totals.clear();
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next)
mark.totals.emplace_back(site, site->ticks.load(std::memory_order_relaxed));
mark.tsc = __rdtsc();
mark.ns = now_ns();
} catch (...) { mark.totals.clear(); }
}
std::vector<ZoneTime> zones_since(const ZoneMark& mark, std::size_t most) noexcept {
std::vector<ZoneTime> result;
try {
const auto ns = now_ns(), tsc = __rdtsc();
if (ns <= mark.ns || tsc <= mark.tsc) return result;
// Ticks to milliseconds over this very stretch.
const double ms_per_tick = static_cast<double>(ns - mark.ns) / 1e6 / static_cast<double>(tsc - mark.tsc);
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next) {
const auto now = site->ticks.load(std::memory_order_relaxed);
// (Sites only ever join the list's head, so one the mark lacks began at zero; the
// profiler window's summary sets them back to zero each second, and a total below
// the mark's started again since.)
const auto found = std::ranges::find(mark.totals, site, &std::pair<const Site*, std::uint64_t>::first);
const auto before = found == mark.totals.end() ? 0 : found->second;
const auto ticks = now >= before ? now - before : now;
if (!ticks) continue;
const auto ms = static_cast<double>(ticks) * ms_per_tick;
const auto same = std::ranges::find(result, std::string_view(site->name), &ZoneTime::name);
if (same != result.end()) same->milliseconds += ms;
else result.push_back({site->name, ms});
}
std::ranges::sort(result, [](const ZoneTime& a, const ZoneTime& b) { return a.milliseconds > b.milliseconds; });
if (result.size() > most) result.resize(most);
} catch (...) { result.clear(); }
return result;
}
std::shared_ptr<const Summary> summary() noexcept {
std::lock_guard lock(summary_mutex);
return latest_summary;
+22
View File
@@ -71,6 +71,28 @@ enum class Series { client_interval, client_frame, client_own, present_interval
// The most recent values in milliseconds, oldest first; returns how many were written.
std::size_t history(Series series, std::span<float> out) noexcept;
// ---- describing a freeze ----------------------------------------------------------------------
// Kept on while this launch sends timings (Runtime/frame_timing.cpp): zones then time ReSkate's
// work whatever the profiler window shows, so a frame that froze can say what of ReSkate's ran in
// it. Off again leaves them to the window.
void keep_zones(bool on) noexcept;
// ReSkate's own share of the latest client update, and how many presents there have been: both
// counted always (a store and an add).
std::uint64_t last_client_own_ns() noexcept;
std::uint64_t presents() noexcept;
// Every zone's running total at one moment; zones_since then gives the zones that took the most
// time between that moment and now, longest first. A mark is reused from frame to frame.
struct ZoneMark {
std::vector<std::pair<const Site*, std::uint64_t>> totals;
std::uint64_t tsc{}, ns{};
};
void mark_zones(ZoneMark& mark) noexcept;
struct ZoneTime {
std::string name;
double milliseconds{};
};
std::vector<ZoneTime> zones_since(const ZoneMark& mark, std::size_t most) 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.
@@ -1,6 +1,7 @@
#pragma once
#include "profiler.h"
#include <Windows.h>
#include <array>
namespace dingosdk::profiler::detail {
// Threads the summary and the sampler name: the one running the client update, and the one
@@ -8,6 +9,12 @@ namespace dingosdk::profiler::detail {
extern std::atomic<std::uint32_t> client_thread, present_thread;
// True while a sample runs; keeps the monitor (and so the zones) alive.
extern std::atomic<bool> sampling;
// While a sample of the present thread runs: when each frame was presented (now_ns), so its report
// can give the slowest frames as the client one does. One writer, the present thread.
constexpr std::size_t present_mark_capacity = 1 << 14;
extern std::atomic<bool> marking_presents;
extern std::atomic<std::uint32_t> present_mark_count;
extern std::array<std::atomic<std::uint64_t>, present_mark_capacity> present_marks;
// 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.
+79
View File
@@ -286,6 +286,12 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
const HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
const auto period = std::chrono::nanoseconds(1'000'000'000 / request.rate);
// Sampling the present thread: when each frame is presented, for its slowest frames.
const bool presents = request.target == Target::present;
if (presents) {
detail::present_mark_count.store(0, std::memory_order_relaxed);
detail::marking_presents.store(true, std::memory_order_release);
}
const auto start = std::chrono::steady_clock::now();
const auto finish = start + std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(request.seconds));
auto next = start;
@@ -337,6 +343,18 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
}
if (timer) CloseHandle(timer);
close();
// Each present, in microseconds into the sample as the samples are.
std::vector<std::uint32_t> present_at;
if (presents) {
detail::marking_presents.store(false, std::memory_order_release);
const auto start_ns = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(start.time_since_epoch()).count());
const auto marked = std::min<std::size_t>(detail::present_mark_count.load(std::memory_order_acquire), detail::present_mark_capacity);
for (std::size_t i = 0; i < marked; ++i) {
const auto at = detail::present_marks[i].load(std::memory_order_relaxed);
if (at > start_ns) present_at.push_back(static_cast<std::uint32_t>((at - start_ns) / 1000));
}
std::ranges::sort(present_at);
}
report.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
report.progress = 1;
@@ -487,6 +505,67 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
}
}
// ---- the slowest presented frames: from one present to the next ----
// A hitch shows here as one long gap between presents. What the present thread did in it:
// the shares of its samples in that gap, and how many of them were waiting (on the GPU, the
// driver or another thread) rather than working.
if (presents && present_at.size() >= 9 && !records.empty()) {
struct Gap { std::uint32_t from{}, to{}; };
std::vector<Gap> gaps;
for (std::size_t i = 1; i < present_at.size(); ++i) gaps.push_back({present_at[i - 1], present_at[i]});
std::vector<std::uint32_t> lengths;
for (const auto& gap : gaps) lengths.push_back(gap.to - gap.from);
std::ranges::sort(lengths);
const auto median = std::max<std::uint32_t>(1, lengths[lengths.size() / 2]);
std::ranges::sort(gaps, [](const Gap& a, const Gap& b) { return a.to - a.from > b.to - b.from; });
hitches += std::format("Slowest presented frames, of {} (the usual one takes {:.1f} ms from one present to the next).\n"
"Under each: what the present thread spent that time in, as shares of its samples then.\n",
gaps.size(), median / 1000.0);
std::vector<std::uintptr_t> walked;
for (std::size_t n = 0; n < gaps.size() && n < 6; ++n) {
const auto& gap = gaps[n];
const auto length = gap.to - gap.from;
if (length < median * 2 && n) break;
std::unordered_map<std::uintptr_t, Counts> inner;
std::uint32_t samples{}, waited{};
// (The samples are in time order.)
const auto first = std::ranges::lower_bound(records, gap.from, {}, &Record::at_us);
for (auto it = first; it != records.end() && it->at_us < gap.to; ++it) {
walked.clear();
const auto* sample = pool.data() + it->offset;
for (std::uint32_t f = 0; f < it->depth; ++f) {
const Module* module{};
walked.push_back(function_start(modules, f ? sample[f] - 1 : sample[f], &module));
owners[walked.back()] = module;
}
if (walked.empty()) continue;
++samples;
waited += waiting(name_of(walked.front(), owners[walked.front()])) ? 1u : 0u;
for (std::size_t f = 0; f < walked.size(); ++f) {
auto& counts = inner[walked[f]];
counts.module = owners[walked[f]];
if (!f) ++counts.self;
const auto here = walked.begin() + static_cast<std::ptrdiff_t>(f);
if (std::find(walked.begin(), here, walked[f]) == here) ++counts.total;
}
}
hitches += std::format("\n {:.1f} ms, {:.2f} s into the sample ({} samples, {:.0f}% of them waiting)\n", length / 1000.0,
gap.from / 1e6, samples, samples ? 100.0 * waited / samples : 0.0);
if (!samples) continue;
std::vector<std::pair<std::uintptr_t, Counts>> order(inner.begin(), inner.end());
// The work, not the way down to it: what nearly every sample passes through is left out.
std::erase_if(order, [&](const auto& entry) { return entry.second.total * 100 >= samples * 97; });
std::ranges::sort(order, [](const auto& a, const auto& b) { return a.second.total > b.second.total; });
for (std::size_t i = 0; i < order.size() && i < 16; ++i)
hitches += std::format(" {:5.1f}% (self {:4.1f}%) {}\n", 100.0 * order[i].second.total / samples,
100.0 * order[i].second.self / samples, name_of(order[i].first, order[i].second.module));
}
hitches += "\n";
} else if (presents) {
hitches += std::format("No frame times: {} present(s) were seen while sampling (ReSkate's present hook notes them).\n\n",
present_at.size());
}
// ---- files next to ReSkate.log ----
const auto directory = logging::status().directory;
SYSTEMTIME now{};
+6 -1
View File
@@ -212,7 +212,12 @@ void apply_changes(sqlite3* db, SaveSchema schema, const Json* before, const Jso
void configure_writer(sqlite3* db) {
Statement mode(db, "PRAGMA journal_mode=WAL");
check(mode.row() && mode.text(0) == "wal" && !mode.row(), "Cannot enable save database WAL");
execute(db, "PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA wal_autocheckpoint=256; PRAGMA journal_size_limit=1048576; PRAGMA max_page_count=16384;");
// NORMAL, not FULL: in WAL mode a commit then no longer waits for the disk. A transaction is
// still kept when the game crashes (it is in the operating system's hands); only a power
// loss or a crash of Windows itself can take back the last commits, never corrupt the file.
// FULL made every commit wait for a flush, on the game's own threads: each play event the
// game saves, each option, under locks the game's hooks wait on (2026-10-15).
execute(db, "PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL; PRAGMA wal_autocheckpoint=256; PRAGMA journal_size_limit=1048576; PRAGMA max_page_count=16384;");
}
void check_size(const std::filesystem::path& path) { check(std::filesystem::file_size(path) <= max_bytes, "Save database exceeds size limit"); }
void clear_staging(const std::filesystem::path& path) {
@@ -0,0 +1,31 @@
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
// The size of the renderer's shader-visible CBV/SRV/UAV descriptor heaps: a persistent part
// (120,000) and, in each frame's heap, a part for that frame's descriptors: what is left of a
// total of 333,333 (a third of D3D12's 1,000,000), capped at 131,072.
#pragma once
#include <array>
#include <cstdint>
namespace dingosdk::game::build::v20260929::descriptor_heap_size {
// In the renderer's device setup (0x0410e3f0), where the total each heap may have (333,333, from
// the device or from the platform's own description, PQXD; both paths join here, at the first
// instruction) is capped at 1,000,000 unless the device's resource binding is tier 3:
// cmp dword ptr [rsi + 0x7a4], 3
// je +0x0a ; past the cap
// mov edx, 1000000 / cmp ecx, edx / cmova ecx, edx (left as they are)
inline constexpr std::uintptr_t total_cap_rva = 0x0410eaa0;
inline constexpr std::array<std::uint8_t, 9> total_cap{0x83, 0xbe, 0xa4, 0x07, 0x00, 0x00, 0x03, 0x74, 0x0a};
// In the renderer's device setup (0x0410e3f0), the per-frame part: what the device allows past
// the persistent part, divided among the heaps, then capped by the setting at desc+8
// (0xffffffff: no cap):
// mov ecx, dword ptr [r14 + 8] ; the cap
// div r9d ; eax = room / heaps
// mov edi, eax
// mov eax, 0xffffffff
// cmp ecx, eax / je / cmp edi, ecx / cmovb ecx, edi / mov edi, ecx (left as they are)
// No branch lands inside these four instructions.
inline constexpr std::uintptr_t cap_load_rva = 0x0410eaee;
inline constexpr std::array<std::uint8_t, 14> cap_load{
0x41, 0x8b, 0x4e, 0x08, 0x41, 0xf7, 0xf1, 0x8b, 0xf8, 0xb8, 0xff, 0xff, 0xff, 0xff};
} // namespace dingosdk::game::build::v20260929::descriptor_heap_size
@@ -0,0 +1,21 @@
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
// The arena EA's error reporting (EadpErrorsData) allocates from: 80 MiB, made
// about seven seconds into startup, before the named settings can be changed.
#pragma once
#include <array>
#include <cstdint>
namespace dingosdk::game::build::v20260929::eadp_errors_arena {
// In the arena's factory (0x02318660): the load of its size from the settings
// object (EadpErrorsData.EadpErrorsDataPlatformSpecificMemPoolSize) and the
// vtable load after it, the two complete instructions moved into the stub.
// mov eax, dword ptr [rax + 0x7c]
// lea rcx, [rip + 0x03fffd17] ; 0x063183d8
// No branch in the factory lands inside them; the factory goes on at resume_rva.
inline constexpr std::uintptr_t size_load_rva = 0x023186b7;
inline constexpr std::array<std::uint8_t, 10> size_load{
0x8b, 0x40, 0x7c, 0x48, 0x8d, 0x0d, 0x17, 0xfd, 0xff, 0x03};
inline constexpr std::uintptr_t vtable_rva = 0x063183d8;
inline constexpr std::uintptr_t resume_rva = 0x023186c1;
} // namespace dingosdk::game::build::v20260929::eadp_errors_arena
@@ -0,0 +1,15 @@
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
// The game's fatal handlers catch most crashes themselves (ReSkate's last-chance filter never
// sees them) and write their own minidump, of thread stacks only, in-process: three callers
// (0x18e3ea1 "Frostbite MiniDump", 0x2d28c9d, 0x2d2bfee) through one import thunk.
#pragma once
#include <array>
#include <cstdint>
namespace dingosdk::game::build::v20260929::game_fatal_dump {
// MiniDumpWriteDump's import slot (api-ms-win-core-debug-minidump-l1-1-0.dll).
inline constexpr std::uintptr_t import_slot = 0x05feb2e0;
// The thunk every caller goes through: jmp qword ptr [import_slot].
inline constexpr std::uintptr_t thunk = 0x0546dbc6;
inline constexpr std::array<std::uint8_t, 6> thunk_bytes{0xff, 0x25, 0x14, 0xd7, 0xb7, 0x00};
} // namespace dingosdk::game::build::v20260929::game_fatal_dump
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <array>
#include <cstdint>
// The light renderer (Extension/World/light_table_guard.cpp). Each kind of light has a table of 64
// pages of 128 and an ID allocator at manager+0xb8; nothing checks the IDs against the table.
namespace dingosdk::game::build::v20260929::light_tables {
inline constexpr std::uintptr_t renderer = 0x77ef140; // the light renderer, null until made
// The allocator: the free list head at +0xe8 (low 32 bits 0xffffffff when empty), fresh IDs given at +0xf0.
inline constexpr std::uint32_t allocator = 0xb8, free_head = 0xe8, issued = 0xf0;
inline constexpr std::uint32_t capacity = 64 * 128;
inline constexpr Fingerprint take_id{0x3e844f0, {
0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0xb9,0xe8,0x00,
0x00,0x00,0x48,0x8b,0xf1,0x90,0x8b,0xdf,0x83,0xff,0xff,0x74,0x45,0x48,0xbd,0x00}};
// factory(creator, creation info): the entity, or null. The six begin alike.
inline constexpr std::array<unsigned char, 32> factory_head{
0x40,0x53,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x58,0x48,0x8b,0x9a,0x40,0x01,0x00,
0x00,0x48,0x8b,0xfa,0x4c,0x8b,0xf1,0x48,0x8d,0x54,0x24,0x30,0x48,0x8b,0x4f,0x10};
struct Kind {
const char* name;
std::uint32_t manager; // in the renderer
Fingerprint factory;
};
inline constexpr Kind kinds[]{
{"OldPointLight", 0x60, {0x52d3f50, factory_head}},
{"OldSpotLight", 0x570, {0x52d4480, factory_head}},
{"PbrSphereLight", 0xa80, {0x52d0e00, factory_head}},
{"PbrSpotLight", 0xf90, {0x52d1740, factory_head}},
{"PbrTubeLight", 0x14a0, {0x52d1d40, factory_head}},
{"PbrRectangularLight", 0x19b0, {0x52d2680, factory_head}},
};
}
+5
View File
@@ -72,6 +72,11 @@ inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0
// Queue selector shared by UI Next and natural end-of-track selection. The queue
// supplies its own channel and registered playlist asset; do not hard-code IDs.
inline constexpr Fingerprint playback_select_next_contract{0x14f8ed0, {0x48,0x89,0x54,0x24,0x10,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xc8,0x48,0x81,0xec,0x38,0x01,0x00,0x00,0x4c,0x8b,0xe1}};
// The callback the queue selector hands the game's song scorer for a playlist asset with a
// selector: (u32 place, u32 *out, context) -> *out, a number of the song at that place of the
// asset's songs (song + 0x7c), or 0 where the place holds none. The whole function (32 bytes):
// it does not look at how many songs the asset has.
inline constexpr Fingerprint playback_song_score_contract{0x14f23a0, {0x49,0x8b,0x00,0x44,0x8b,0xc9,0x48,0x8b,0x08,0x4a,0x8b,0x04,0xc9,0x48,0x83,0xe0,0xfb,0x74,0x06,0x8b,0x40,0x7c,0x89,0x02,0xc3,0xc7,0x02,0x00,0x00,0x00,0x00,0xc3}};
// Native playlist playback-mode setter (PlaylistPlaybackMode, not MusicPlaylistType).
inline constexpr Fingerprint playback_mode_setter_contract{0x443aa0, {0x44,0x8b,0x91,0xa8,0x00,0x00,0x00,0x4c,0x8b,0x99,0xa0,0x00,0x00,0x00,0x44,0x8b,0xca,0x8b,0xc2,0x33,0xd2,0x49,0xf7,0xf2,0x8b,0xc2,0x49,0x8b,0x04,0xc3,0x48,0x85}};
// Native channel-to-playlist binding entrypoint, used by the generated UI/script thunk.
@@ -0,0 +1,22 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstdint>
// A mesh's render settings (Extension/World/mesh_settings_guard.cpp).
namespace dingosdk::game::build::v20260929::mesh_settings {
// apply_settings(mesh, settings): as a level's meshes load (0x3f59610, for each mesh whose flag
// at + 0x3b0 is clear), it builds the settings object of the mesh's asset in place at mesh + 0x340
// (0x70 bytes, a reflected instance of 0x76f26f0) when the asset has one; settings is that
// object's source, null when it has none. Its caller sets the flag either way.
inline constexpr Fingerprint apply_settings{0x3f5beb0, {
0x48,0x89,0x5c,0x24,0x10,0x57,0x48,0x83,0xec,0x60,0x80,0xb9,0xb0,0x03,0x00,0x00,
0x00,0x48,0x8b,0xfa,0x48,0x8b,0xd9,0x74,0x20,0x48,0x81,0xc1,0x40,0x03,0x00,0x00}};
// The mesh (0x3c0 bytes, made at 0x3f33770) leaves + 0x340 to + 0x3af as the allocation had it.
inline constexpr std::ptrdiff_t settings_offset = 0x340;
// The settings object's string the mesh frees as it unloads (0x3f5fa20, through 0x1584c50,
// whenever the flag is set): with no settings object it is whatever the allocation left there.
inline constexpr std::ptrdiff_t unload_string_offset = 0x360;
inline constexpr std::ptrdiff_t applied_flag_offset = 0x3b0;
// The game's empty string: a string that holds it frees nothing (0x1584c50 compares with it).
inline constexpr std::uintptr_t empty_string = 0x72fe704;
}
@@ -47,6 +47,13 @@ inline constexpr std::array<unsigned char, 16> add_shader_preset_prefix{
inline constexpr std::uintptr_t script_preset_reference = 0x336f060;
inline constexpr std::array<unsigned char, 8> script_preset_reference_prefix{0x4c,0x8d,0x05,0x81,0xa2,0x0d,0x03,0xe9};
inline constexpr std::uintptr_t referenced_preset = 0x16fe9c0;
// An AppearanceShaderExpressionPreset as loaded (its EBX layout): ParamInfo, the array of its
// AppearanceExpressionParamInfo at +0x20, 40 bytes each: the value's boxed {TypeInfo*, data*} at
// +0, its Parameter at +0x10, its ParamKey at +0x18. Applying a preset to a material (0x141314340,
// under 0x141330210) type-checks every entry whose data is set, reading the TypeInfo unchecked.
inline constexpr std::size_t preset_parameters = 0x20;
inline constexpr std::size_t preset_parameter_size = 40;
inline constexpr std::size_t preset_parameter_key = 0x18;
inline constexpr std::array<unsigned char, 17> referenced_preset_prefix{
0x48,0x8b,0x09,0x48,0x85,0xc9,0x74,0x07,0x48,0x8b,0x01,0x48,0xff,0x60,0x18,0x33,0xc0};
}
+4
View File
@@ -34,6 +34,10 @@ inline constexpr std::size_t component_owner = 0x18;
// (rig, joint id, 1): the joint's index in the rig's skeleton, or -1. Reads no pose.
inline constexpr Function joint_index{
0x12e08b0, {0x48,0x8b,0x89,0x38,0x01,0x00,0x00,0xe9,0x84,0x76,0x5b,0x03,0xcc,0xcc,0xcc,0xcc}};
// joint_index looks the joint up in the skeleton's map (EASTL: a node's right child at +0, its
// left at +8, its joint id at +0x20; the root at skeleton +0x68), with no bound on the walk
// (0x4897f40).
inline constexpr std::size_t skeleton_joints_root = 0x68, joint_node_right = 0, joint_node_left = 8, joint_node_id = 0x20;
// An effects component's rig: component -> its rig's owner -> the rig; the rig's skeleton. An
// effect set's effects (an array of pointers), and the joint each sits on (-1: none).
inline constexpr std::size_t component_rig_owner = 0x50, rig_owner_rig = 0x78, rig_skeleton = 0x138, set_items = 0x30,
@@ -29,6 +29,9 @@ inline constexpr auto constructor_entry = hex_bytes(
inline constexpr auto index_table_constructor_entry = hex_bytes(
"4053555657415641574883ec78c5f9efc033ed488bf948896c2428488d4c2440");
// mov ecx, 0x2daa00 -> mov ecx, 0x11ab080 (the state owner's size, 0x3efd347).
inline constexpr auto other_renderer_size_before = hex_bytes("b900aa2d00");
inline constexpr auto other_renderer_size_after = hex_bytes("b980b01a01");
inline constexpr auto vector_begin_before = hex_bytes("488d81b0000000");
inline constexpr auto vector_begin_after = hex_bytes("488b8190000000");
inline constexpr auto vector_bytes_before = hex_bytes("480500001e00");
@@ -90,10 +93,22 @@ inline constexpr std::ptrdiff_t resource_state_vector_offset = 0x2da920;
inline constexpr std::ptrdiff_t relation_mask_vector_offset = 0x114ab60;
inline constexpr std::uintptr_t constructor_rva = 0x03e7f300;
// The constructor is the base of two renderers. Only the one constructed here
// (vtable 0x64f2c10, 0x11ab080 bytes) has the resource-state and relation-mask
// vectors: every read of owner+0x2da920 and owner+0x114ab60 is in its code. The
// other (constructed at 0x030a0a46, vtable 0x6414168, kept at 0x75f2700) keeps
// fields of its own at +0x2da920. Both use the metadata: the patched view
// (0x030e6bff, called from 0x30a7e18 with the other renderer) and append
// (0x03e94fe6, called from 0x30d8ebb) read its pointers at +0x11aae28/+0x11aae30.
// The other renderer was allocated 0x2daa00 bytes, which ends before them, so it
// is allocated the state owner's 0x11ab080 instead (other_renderer_size, above).
inline constexpr std::uintptr_t state_owner_call_rva = 0x03f33a46;
inline constexpr std::uintptr_t index_table_constructor_rva = 0x03e7e400;
inline constexpr std::uintptr_t resource_flags_priority_rva = 0x03f56073;
inline constexpr std::uintptr_t resource_flags_visibility_rva = 0x03f5789a;
inline constexpr Patch patches[]{
// The other renderer's allocation, large enough for the metadata pointers.
{0x030c64cc, other_renderer_size_before, other_renderer_size_after},
{0x03e7f33c, vector_begin_before, vector_begin_after},
{0x03e7f351, vector_bytes_before, vector_bytes_after},
{0x03e94e99, capacity_before, capacity_after},
@@ -11,6 +11,17 @@ inline constexpr std::uintptr_t submit_frame = 0x34f4610;
inline constexpr std::array<unsigned char, 24> submit_frame_prefix{
0x40,0x57,0x48,0x81,0xec,0x50,0x08,0x00,0x00,0x48,0x8b,0x05,0xa0,0xfd,0xcc,0x03,
0x48,0x33,0xc4,0x48,0x89,0x84,0x24,0x20};
// Maps the export dialog's 8/12/18 Mbps selection to the H.264 encoder rate.
// Raise all three outputs so export cannot silently fall back to the low-bitrate
// preset. The replacement rate is 100 Mbps (100000 kbps).
inline constexpr std::uintptr_t bitrate_switch = 0x62c240;
inline constexpr std::array<unsigned char, 34> bitrate_switch_prefix{
0x2b,0x85,0xc9,0x74,0x18,0x83,0xe9,0x01,0x74,0x05,0x83,0xf9,0x01,0x74,0x07,0xb8,
0xe0,0x2e,0x00,0x00,0xeb,0x0c,0xb8,0x50,0x46,0x00,0x00,0xeb,0x05,0xb8,0x40,0x1f,
0x00,0x00};
inline constexpr std::array<std::size_t, 3> bitrate_immediate_offsets{16, 23, 30};
inline constexpr std::array<std::uint32_t, 3> stock_bitrates_kbps{12000, 18000, 8000};
inline constexpr std::uint32_t replay_bitrate_kbps = 100000;
// The encoder the replay editor exports with; the engine has others.
inline constexpr std::uintptr_t replay_encoder_vtable = 0x608c0b0;
// Encoder byte: a frame may be encoded. A world update sets it and an encoded
@@ -0,0 +1,78 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstddef>
#include <cstdint>
// The replay editor's camera keyframes (Extension/ReplayKeyframes/replay_keyframes.h): framing keys
// (Behaviour, Subject, offsets, FOV), the free camera's among them, played as cubic Hermite segments
// between neighbouring keys. (The camera's other track, whole transforms played by 0x6222e0, isn't
// used by the free camera and is left alone.)
namespace dingosdk::game::build::v20260929::replay_keyframes {
// ---- Framing track: segments built when keys change ----
// rebuild(manager, session): any key edit ends here. It builds a segment for every framing key of the
// session with segment_build and sends the lot, with the transform keys, to the camera object. The
// manager sends one package at a time (its handle is at manager + 0x40): the session it rebuilt last
// is the one the camera plays.
inline constexpr Fingerprint session_rebuild{0x62c500, {
0x89,0x54,0x24,0x10,0x48,0x89,0x4c,0x24,0x08,0x55,0x53,0x57,0x41,0x57,0x48,0x8d,
0xac,0x24,0x08,0xfe,0xff,0xff,0x48,0x81,0xec,0xf8,0x02,0x00,0x00,0x4c,0x8b,0xf9}};
// Segment* segment_build(manager, Segment* out, Node* keys_end, Node** key): the segment from a key
// to the next. keys_end is the anchor of the session's key map (session node + 0x60: [1] is the first
// key); the session's id is the int at session node + 0x20.
inline constexpr Fingerprint segment_build{0x60c7a0, {
0x40,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0xf0,
0x00,0x00,0x00,0x4c,0x8b,0xe1,0x4d,0x8b,0xf9,0x48,0x8b,0xca,0x4d,0x8b,0xe8,0x48}};
inline constexpr std::size_t session_id_before_keys = 0x60 - 0x20;
// void slot_build(manager, const void* value, double time, int behaviour, int subject, Slot* out):
// one key as a segment slot, under a Behaviour and Subject (segment_build passes the segment's own).
inline constexpr Fingerprint slot_build{0x60ec20, {
0x4c,0x8b,0xdc,0x49,0x89,0x73,0x10,0x57,0x48,0x81,0xec,0x60,0x01,0x00,0x00,0x48,
0x8b,0x82,0xf0,0x00,0x00,0x00,0x48,0x8b,0xfa,0x48,0x8b,0xb4,0x24,0x98,0x01,0x00}};
// void slot_unwrap(Slot* adjust, const Slot* reference, int behaviour): adjust's angles to within π
// of the reference's.
inline constexpr Fingerprint slot_unwrap{0x61abd0, {
0x48,0x83,0xec,0x48,0x45,0x85,0xc0,0x75,0x6d,0xc5,0xfa,0x10,0x52,0x48,0xc5,0xfa,
0x10,0x49,0x48,0xc5,0xf2,0x5c,0xc2,0xc5,0xf8,0x54,0x05,0xf1,0xb9,0xa5,0x05,0xc5}};
// bool segment_eval(camera, double time, const Segment*, Out*): plays a framing segment (every frame;
// not hooked: what it plays is decided when the segment is built), every lane through the game's
// spline (p0, p1, p2, p3, s, k1, k2):
// m1 = (1 − k1)·0.5·(p2 − p0), m2 = (1 − k2)·0.5·(p3 − p1), Hermite from p1 to p2.
inline constexpr Fingerprint segment_eval{0x6220d0, {
0x48,0x8b,0xc4,0x48,0x89,0x58,0x08,0x57,0x48,0x81,0xec,0x50,0x01,0x00,0x00,0xc5,
0xfc,0x10,0x05,0xe9,0x15,0xdb,0x05,0xc5,0xfc,0x10,0x15,0x01,0x16,0xdb,0x05,0x41}};
// A segment this short or shorter plays as its end (s = 1).
inline constexpr double tiny_gap = 1.0 / 65536.0;
// The manager singleton.
inline constexpr std::uintptr_t manager = 0x71f2130;
inline constexpr std::size_t manager_sessions = 0x10; // EASTL map<int, Session>: anchor; root at +0x20
// A key's node in a session's framing map: time at + 0x20, value at + 0x30.
inline constexpr std::size_t framing_key_time = 0x20;
inline constexpr std::size_t framing_key_value = 0x30;
// The segment (0x1e8 bytes): four slots of 0x60 bytes, two blocks of twelve k, Behaviour, Subject.
inline constexpr std::size_t slot_size = 0x60;
inline constexpr std::size_t slot_floats = slot_size / sizeof(float);
inline constexpr std::size_t slot_time = 0x40; // double
inline constexpr std::size_t segment_next = 0x000; // p3, rescaled to one segment's time away
inline constexpr std::size_t segment_to = 0x060; // p2
inline constexpr std::size_t segment_previous = 0x0c0; // p0, rescaled likewise
inline constexpr std::size_t segment_from = 0x120; // p1
inline constexpr std::size_t segment_k_to = 0x180; // twelve floats: the k of the to key
inline constexpr std::size_t segment_k_from = 0x1b0; // and of the from key
inline constexpr std::size_t segment_k_count = 12;
inline constexpr std::size_t segment_behaviour = 0x1e0;
// The free camera's keys play under this Behaviour (with Subject 0): seen in game, 2026-10-16.
inline constexpr int free_camera_behaviour = 1;
inline constexpr std::size_t segment_subject = 0x1e4;
inline constexpr std::size_t segment_size = 0x1e8;
// Which slot floats each Behaviour plays, and with which k (segment_eval's evaluators 0x609a50,
// 0x608e10 and 0x609fe0, plus the FOV lane segment_eval plays itself).
struct Lane {
unsigned char slot_float, k;
};
inline constexpr Lane behaviour_0_lanes[]{{0, 0}, {1, 1}, {2, 2}, {8, 4}, {9, 5}, {10, 6}, {18, 8}, {19, 9}, {21, 10}};
inline constexpr Lane behaviour_1_lanes[]{{12, 0}, {13, 1}, {14, 2}, {4, 4}, {5, 5}, {6, 6}, {21, 10}};
inline constexpr Lane behaviour_2_lanes[]{{0, 0}, {1, 1}, {2, 2}, {4, 4}, {5, 5}, {6, 6}, {18, 8}, {19, 9}, {21, 10}};
}
@@ -0,0 +1,34 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <array>
#include <cstdint>
// The game's expression shader databases and their registry (Extension/World/shader_registry_guard.cpp).
// A level's `*_win32_shaderstate_db` resource (ExpressionShaderDatabase, handler 0x3f84cf0) holds a
// blob of 0xa8-byte records; its object keeps one 0x70-byte item per record in a vector
// (begin +0x38, end +0x40) and the lookup ("MemoryInBuffer", pointer +0x18, size +0x20): u32 key
// count, then per key u64 key, u32 n, u32 record index[n]. Registering (0x3f71ac0, under the
// registry's lock, from the shader batch job 0x3f8a390) first adds, per key, the items
// `items + index * 0x70` to the registry's interned item lists (0x3f72280, no bounds check), then
// flags every item (+0x48) and records the registry (+0x30). The destructor unregisters through
// +0x30, which removes the flagged items from every list (0x3f79e20 -> 0x3f7aa10 -> 0x3f7a050).
// An index past the records, or a database destroyed before its queued registration ran, leaves
// a pointer in a list that nothing removes; the next walk of the lists (0x3f7ce90, reading
// *(item + 0x40)) crashes on it (Skate.exe+0x3f7cf33 / +0x3f7ceed / +0x20960b, mostly on custom
// map changes, 2026-10-17).
namespace dingosdk::game::build::v20260929::shader_registry {
// ExpressionShaderDatabase(this, allocator, blob, lookup, lookup size) -> this.
inline constexpr Fingerprint construct{0x3f7ee10, {
0x48,0x89,0x5c,0x24,0x18,0x48,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,
0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x45,0x33,0xd2,0x4c,0x89,0x49,0x18}};
// ~ExpressionShaderDatabase(this, delete flags) -> this.
inline constexpr Fingerprint destroy{0x3f7fb50, {
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x05,0x77,0xf9,0x57,
0x02,0x48,0x8b,0xd9,0x48,0x89,0x01,0x8b,0xfa,0x48,0x8b,0x49,0x30,0x48,0x85,0xc9}};
// register(registrar, database).
inline constexpr Fingerprint register_database{0x3f71ac0, {
0x48,0x89,0x54,0x24,0x10,0x53,0x41,0x54,0x41,0x55,0x41,0x57,0x48,0x83,0xec,0x48,
0x4c,0x8b,0xe9,0x4c,0x8b,0xe2,0x48,0x8b,0x49,0x08,0xe8,0xa1,0x07,0x00,0x00,0x49}};
inline constexpr std::uintptr_t lookup_data = 0x18, lookup_size = 0x20, items_begin = 0x38, items_end = 0x40;
inline constexpr std::uintptr_t item_size = 0x70;
}
@@ -0,0 +1,31 @@
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
// A spatial transaction queue (SpatialQueryManager's, and one in each world's client and render
// spatial managers) hands every object it tracks a cull ID from a free list. The list is made
// once, when the queue is, at SpatialQueryManagerSettings.InitialCullIdListSize entries (the
// game's config: 40,000; 80,000 in GameView.cfg), and never grows: an insert with the list empty
// reads entry 0xffffffff of it and crashes (0x489cfe9, loading a large custom map).
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
namespace dingosdk::game::build::v20260929::spatial_cull_ids {
// make_list(list, allocator): looks the settings up, sizes the list's two arrays (u32 next IDs,
// u64 per-ID entries) to InitialCullIdListSize and chains them into the free list. Its only
// callers are the two queue constructors (0x489b518, 0x489ba10).
inline constexpr std::uintptr_t make_list_rva = 0x0489b770;
inline constexpr std::array<std::uint8_t, 15> make_list_entry{
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x56, 0x48, 0x83, 0xec, 0x20, 0x45, 0x33, 0xf6, 0x48};
// lea rdx, [settings type 0x77ae5e0] ... mov rcx, [settings manager 0x7482ce0];
// call settings_lookup; test rax, rax; je; mov esi, [rax + 0x28]; jmp; mov esi, 1.
inline constexpr std::uintptr_t settings_type_load_rva = 0x0489b78d;
inline constexpr std::array<std::uint8_t, 7> settings_type_load{0x48, 0x8d, 0x15, 0x4c, 0x2e, 0xf1, 0x02};
inline constexpr std::uintptr_t size_read_rva = 0x0489b7ae;
inline constexpr std::array<std::uint8_t, 27> size_read{
0x48, 0x8b, 0x0d, 0x2b, 0x75, 0xbe, 0x02, 0xe8, 0x36, 0xb7, 0x03, 0xfd, 0x48, 0x85,
0xc0, 0x74, 0x05, 0x8b, 0x70, 0x28, 0xeb, 0x05, 0xbe, 0x01, 0x00, 0x00, 0x00};
inline constexpr std::uintptr_t settings_manager = 0x07482ce0;
inline constexpr std::uintptr_t settings_lookup = 0x018d6ef0;
inline constexpr std::uintptr_t settings_type = 0x077ae5e0;
inline constexpr std::size_t initial_cull_id_list_size = 0x28;
} // namespace dingosdk::game::build::v20260929::spatial_cull_ids
@@ -0,0 +1,46 @@
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
// Where the three world pools ReSkate enlarges (entity_pages.h, spatial_buckets.h)
// can be read for how full they are: for telling whether a level needs more than
// the game's own sizes.
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
namespace dingosdk::game::build::v20260929::world_pool_usage {
// Entity snapshots: the page allocator, (owner, allocator, pages). It fills the
// snapshot's 128 page pointers at pages[0..127], each page zeroed when handed out,
// so the highest non-zero entry is the highest entity slot in use.
inline constexpr std::uintptr_t entity_page_allocator_rva = 0x048bd000;
inline constexpr std::array<std::uint8_t, 10> entity_page_allocator_entry{
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x56, 0x48, 0x83, 0xec};
// The game's own entity slots (128 pages of 1,024).
inline constexpr std::size_t entity_slots_stock = 131'072;
// Render instances: the pool is [[global] + 0x4a0] + 0x1300 (as the one caller of
// its allocator loads it), and its next-index counter, taken with lock xadd, is
// the most it has ever handed out.
inline constexpr std::uintptr_t render_instance_load_rva = 0x050e59ac;
inline constexpr std::array<std::uint8_t, 27> render_instance_load{
0x48, 0x8b, 0x05, 0x25, 0xcf, 0x7c, 0x02, 0x48, 0x8b, 0xd9, 0x48, 0x8b, 0xd1, 0x48,
0x8b, 0xa8, 0xa0, 0x04, 0x00, 0x00, 0x48, 0x8d, 0x8d, 0x00, 0x13, 0x00, 0x00};
inline constexpr std::uintptr_t render_instance_owner_global = 0x078b28d8;
inline constexpr std::ptrdiff_t render_instance_manager_offset = 0x4a0;
inline constexpr std::ptrdiff_t render_instance_pool_offset = 0x1300;
inline constexpr std::uintptr_t render_instance_counter_rva = 0x050e23ec;
inline constexpr std::array<std::uint8_t, 8> render_instance_counter{
0xf0, 0x0f, 0xc1, 0xb7, 0x08, 0x10, 0x00, 0x00}; // lock xadd [rdi+0x1008], esi
inline constexpr std::ptrdiff_t render_instance_counter_offset = 0x1008;
// The game's own render instances (512 pages of 512).
inline constexpr std::size_t render_instances_stock = 262'144;
// Spatial buckets: a query, (root, ...), four register arguments; the root's node
// count and each node's block count are at spatial_buckets.h's offsets.
inline constexpr std::uintptr_t spatial_query_rva = 0x01967420;
inline constexpr std::array<std::uint8_t, 15> spatial_query_entry{
0x4c, 0x8b, 0xdc, 0x4d, 0x89, 0x4b, 0x20, 0x41, 0x55, 0x41, 0x56, 0x48, 0x83, 0xec, 0x68};
inline constexpr std::ptrdiff_t spatial_nodes_offset = 0x130;
// The game's own blocks per bucket (its root bucket overflowed at 2,049).
inline constexpr std::size_t spatial_blocks_stock = 2'048;
} // namespace dingosdk::game::build::v20260929::world_pool_usage
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <cstdint>
// Props spawned by ReSkate itself (Extension/Multiplayer/Remote/native_prop.cpp): an object
// blueprint loaded on the map, created as a client-only entity the way the remote skateboard is
// (skater_entities.h, native_skater.h) and moved every frame by the plain entity transform setter
// (skater_entities::place_entity). Found in game on 2026-10-15 with the traffic cone
// (world/props/common/construction/cmn_prp_trafficcone_01/cmn_prp_trafficcone_01): the creation
// list made one entity, and only place_entity moved it smoothly (the blueprint transform did not).
namespace dingosdk::game::build::v20260929::world_props {
// An ObjectBlueprint's vtable (the cone's). The skater code's blueprint vtable is
// engine::blueprint_vtable; an object blueprint has its own.
inline constexpr std::uintptr_t object_blueprint_vtable = 0x638dfa8;
// The entity a static-model object blueprint creates on the client (the cone's).
inline constexpr std::uintptr_t static_model_entity_vtable = 0x6403720;
} // namespace dingosdk::game::build::v20260929::world_props
+7
View File
@@ -1,6 +1,7 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <string_view>
@@ -27,6 +28,12 @@ inline constexpr std::string_view steam_build_id = "25414733";
// what mods made for it carry too. Data of another number beside a supported Skate.exe is a
// game that was updated or copied over in part, and has to be verified.
inline constexpr std::string_view game_data_changelist = "29544144";
// How many world layers this build's levels have: the catalog players scan from their game and
// a dedicated server reads from world-layers.json. A session's layers are sent as one choice for
// each, in the catalog's order, so both ends must have the same catalog. (2.0.8's server was
// released with the catalog of a game Steam had updated, 263 layers, and no player could join a
// server that shared its layers: 2026-10-13.)
inline constexpr std::size_t world_layer_count = 261;
// The live game's HTTP content cache for this build (catalogue chunks and CDN
// images), published as a ReSkateCache release and installed by the launcher.
inline constexpr std::wstring_view content_cache_url =
+2 -1
View File
@@ -89,7 +89,8 @@ struct ControllerInput {
struct ActionBind {
std::string_view key, name, label, command;
};
inline constexpr std::array<ActionBind, 8> action_binds{{
inline constexpr std::array<ActionBind, 9> action_binds{{
{"reskate_menu", "reskatemenu", "Open the ReSkate menu", "menu"},
{"first_person", "firstperson", "First person", "firstperson toggle"},
{"hide_hud", "hidehud", "Hide HUD", "hideui toggle"},
{"voice_chat", "voicechat", "Voice chat on / off", "mp voice-chat toggle"},
+19
View File
@@ -53,6 +53,24 @@ struct EditorPasteRequest {
std::uint64_t generation{}, revision{};
std::vector<EditorObject> objects;
};
// Removes several objects in one edit (one undo step), as a paste adds several.
struct EditorDeleteRequest {
std::uint64_t generation{}, revision{};
std::vector<std::uint64_t> objects;
};
// A saved set of objects (<profile>/object_sets/<name>.park.json), placed again as one paste on any
// map. Positions are relative to the set's anchor: the middle of its footprint, at its lowest origin.
struct EditorObjectSet {
std::string name, map; // map: where the set was saved, for display only
std::vector<EditorObject> objects;
};
enum class EditorObjectSetAction { save, remove };
struct EditorObjectSetRequest {
EditorObjectSetAction action{};
std::uint64_t generation{};
std::string name;
std::vector<EditorObject> objects; // save: the objects in world space
};
// A mod with a parks/ folder (Mods/<folder>/parks/<map>.park.json).
struct EditorParkMod {
std::string folder, title, author, version, description;
@@ -71,5 +89,6 @@ struct ParkEditorModel {
bool can_load_parks{}; // false for multiplayer guests: only the host loads parks
EditorSurfaceHit surface;
std::uint64_t preview_token{};
std::shared_ptr<const std::vector<EditorObjectSet>> object_sets;
};
} // namespace dingosdk
+23
View File
@@ -169,6 +169,29 @@ inline WorldLayerMapSelection select_world_layer_map(
return result;
}
// A TOD_<n>_<name> layer (or a lighting tier's <tier>_TOD_<n>_<name>): one time of day among its siblings.
inline bool world_layer_time_of_day(std::size_t slot) {
const auto& bundle = world_layer_nodes()[slot].bundle;
auto leaf = bundle.substr(bundle.find_last_of('/') + 1);
for (auto& c : leaf) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
const auto mid = leaf.find("_tod_");
const auto at = leaf.starts_with("tod_") ? 4 : mid == std::string::npos ? leaf.size() : mid + 5;
return at < leaf.size() && leaf[at] >= '1' && leaf[at] <= '9';
}
// A sibling time of day that is to be off but not fully unloaded yet, which `slot` must wait for;
// world_layer_nodes().size() when there is none.
template <class FullyOff>
std::size_t world_layer_old_time_of_day(std::size_t slot, const std::vector<std::optional<bool>>& desired, FullyOff&& fully_off) {
const auto& nodes = world_layer_nodes();
if (!world_layer_time_of_day(slot)) return nodes.size();
for (std::size_t at = 0; at < nodes.size(); ++at)
if (at != slot && nodes[at].parent == nodes[slot].parent && nodes[at].map == nodes[slot].map &&
desired[at] == false && world_layer_time_of_day(at) && !fully_off(at))
return at;
return nodes.size();
}
inline bool world_layer_supported(
std::size_t choice,
const std::vector<bool>& present) noexcept {
@@ -0,0 +1,105 @@
// The EBX type-reference repair: an entry on another fixup's slot, or listed twice, comes out of
// the list, the chunk and RIFF sizes follow, and a document without one is left alone.
#include "Engine/Resource/ebx_fixup_repair.h"
#include <cstring>
#include <iostream>
#include <string_view>
#include <vector>
namespace fb = dingosdk::frostbite;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; }
}
void u32(std::vector<std::byte>& bytes, std::uint32_t value) {
const auto at = bytes.size();
bytes.resize(at + 4);
std::memcpy(bytes.data() + at, &value, 4);
}
std::uint32_t read(const std::vector<std::byte>& bytes, std::size_t at) {
std::uint32_t value{};
std::memcpy(&value, bytes.data() + at, 4);
return value;
}
void chunk(std::vector<std::byte>& riff, std::string_view id, const std::vector<std::byte>& data) {
for (const auto ch : id) riff.push_back(static_cast<std::byte>(ch));
u32(riff, static_cast<std::uint32_t>(data.size()));
riff.insert(riff.end(), data.begin(), data.end());
if (data.size() & 1) riff.push_back(std::byte{});
}
std::vector<std::uint32_t> list_at(const std::vector<std::byte>& bytes, std::size_t at) {
std::vector<std::uint32_t> out(read(bytes, at));
for (std::size_t index = 0; index < out.size(); ++index) out[index] = read(bytes, at + 4 + 4 * index);
return out;
}
// An EBX with one type, one instance at 0x10, a pointer at 0x40, one import relocated at 0x60,
// and the type references given. Returns where the type-reference count lies.
std::vector<std::byte> document(const std::vector<std::uint32_t>& types, std::size_t& typeList) {
std::vector<std::byte> efix(16); // the file guid
u32(efix, 1);
efix.resize(efix.size() + 16); // its type
u32(efix, 0); // no signatures
u32(efix, 1); // exported
u32(efix, 1);
u32(efix, 0x10); // the instance
u32(efix, 1);
u32(efix, 0x40); // a pointer
u32(efix, 0); // no resource references
u32(efix, 1);
efix.resize(efix.size() + 32); // an import
u32(efix, 1);
u32(efix, 0x60); // its relocation
const auto list = efix.size();
u32(efix, static_cast<std::uint32_t>(types.size()));
for (const auto type : types) u32(efix, type);
u32(efix, 0x80);
u32(efix, 0x80);
u32(efix, 0x80); // the sections
std::vector<std::byte> riff;
for (const auto ch : std::string_view("RIFF")) riff.push_back(static_cast<std::byte>(ch));
u32(riff, 0);
for (const auto ch : std::string_view("EBX\0", 4)) riff.push_back(static_cast<std::byte>(ch));
chunk(riff, "EBXD", std::vector<std::byte>(0x90));
typeList = riff.size() + 8 + list;
chunk(riff, "EFIX", efix);
chunk(riff, "EBXX", std::vector<std::byte>(8));
chunk(riff, "REFL", std::vector<std::byte>(5));
const auto size = static_cast<std::uint32_t>(riff.size() - 8);
std::memcpy(riff.data() + 4, &size, 4);
return riff;
}
} // namespace
int main() {
{
std::size_t list{};
const auto clean = document({0x20, 0x28}, list);
check(!fb::ebx::repair_type_fixups(clean), "a document without collisions is left alone");
}
{
// On the import's slot, inside the instance's header, and the same slot twice.
std::size_t list{};
const auto broken = document({0x20, 0x60, 0x18, 0x28, 0x20}, list);
const auto repaired = fb::ebx::repair_type_fixups(broken);
check(repaired.has_value(), "a document with collisions is repaired");
if (repaired) {
check(repaired->removed == 3, "three entries are removed");
check(repaired->bytes.size() == broken.size() - 12, "the document shrinks by those entries");
check(list_at(repaired->bytes, list) == std::vector<std::uint32_t>{0x20, 0x28}, "the clean entries stay, in order");
check(read(repaired->bytes, 4) == read(broken, 4) - 12, "the RIFF size follows");
constexpr std::size_t efixSize = 12 + 8 + 0x90 + 4; // after the header and EBXD
check(read(repaired->bytes, efixSize) == read(broken, efixSize) - 12, "the EFIX size follows");
// EBXX (8 + 8 bytes) and REFL (8 + 5, padded) end the document.
check(std::memcmp(repaired->bytes.data() + repaired->bytes.size() - 30, "EBXX", 4) == 0,
"the chunks after EFIX are carried along");
check(!fb::ebx::repair_type_fixups(repaired->bytes), "the repaired document needs nothing more");
}
}
check(!fb::ebx::repair_type_fixups(std::vector<std::byte>(4)), "something that is not an EBX is left alone");
if (failures) return 1;
std::cout << "ebx fixup repair tests passed\n";
return 0;
}
@@ -8,3 +8,8 @@ extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_Decompress(
*static_cast<unsigned char*>(output) = *static_cast<const unsigned char*>(input);
return 1;
}
// The buffer a decode into `rawSize` bytes needs: as the real codec, a little more than asked.
extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_GetDecodeBufferSize(int, std::int64_t rawSize, int) {
return rawSize + 16;
}
@@ -0,0 +1,87 @@
// The shader program lookup's merge: the rows mods add are combined onto the base, and a row
// that points past the base's shader records is left out (the game reads such an index outside
// its shader database).
#include "Engine/Resource/shader_lookup.h"
#include <cstring>
#include <iostream>
#include <map>
#include <vector>
namespace fb = dingosdk::frostbite;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; }
}
template <typename Type> void append(std::vector<std::byte>& bytes, Type value) {
const auto at = bytes.size();
bytes.resize(at + sizeof(value));
std::memcpy(bytes.data() + at, &value, sizeof(value));
}
template <typename Type> Type read(const std::vector<std::byte>& bytes, std::size_t at) {
Type value{};
std::memcpy(&value, bytes.data() + at, sizeof(value));
return value;
}
// A program lookup: a blob of `records` records (only its count matters here), no relocations,
// and the rows (key -> record indices).
struct Lookup {
std::vector<std::byte> resource, meta;
fb::shader::Table table() const { return {resource, meta}; }
};
Lookup lookup(std::uint32_t records, const std::map<std::uint64_t, std::vector<std::uint32_t>>& rows) {
Lookup out;
append(out.resource, records);
append(out.resource, std::uint32_t{0}); // the rest of the blob
const auto program = static_cast<std::uint32_t>(out.resource.size());
append(out.resource, static_cast<std::uint32_t>(rows.size()));
for (const auto& [key, values] : rows) {
append(out.resource, key);
append(out.resource, static_cast<std::uint32_t>(values.size()));
for (const auto value : values) append(out.resource, value);
}
append(out.meta, program);
append(out.meta, std::uint32_t{0});
append(out.meta, static_cast<std::uint32_t>(out.resource.size() - program));
append(out.meta, std::uint32_t{0});
return out;
}
// The merged rows, read back.
std::map<std::uint64_t, std::vector<std::uint32_t>> rows_of(const fb::shader::MergedTable& merged) {
std::size_t at = read<std::uint32_t>(merged.resourceMeta, 0);
const auto count = read<std::uint32_t>(merged.resource, at);
at += 4;
std::map<std::uint64_t, std::vector<std::uint32_t>> rows;
for (std::uint32_t row = 0; row < count; ++row) {
const auto key = read<std::uint64_t>(merged.resource, at);
const auto values = read<std::uint32_t>(merged.resource, at + 8);
at += 12;
auto& out = rows[key];
for (std::uint32_t value = 0; value < values; ++value, at += 4) out.push_back(read<std::uint32_t>(merged.resource, at));
}
return rows;
}
} // namespace
int main() {
const auto base = lookup(3, {{1, {0}}});
// One mod adds a row onto a program the game has; another brings programs of its own and
// points rows at them (indices 3 and 4, which the base's three records do not have).
const auto good = lookup(3, {{1, {0}}, {2, {2}}});
const auto own = lookup(5, {{1, {0}}, {3, {4}}, {4, {1, 3}}, {5, {1}}});
const std::vector<fb::shader::Table> edits{good.table(), own.table()};
const auto merged = fb::shader::merge_program_lookup(base.table(), edits);
const auto rows = rows_of(merged);
check(merged.added == 2 && rows.contains(2) && rows.contains(5), "Rows onto the base's programs are merged");
check(merged.outside == 2 && !rows.contains(3) && !rows.contains(4),
"Rows pointing past the base's records are left out, also when only one of their indices does");
check(rows.size() == 3 && rows.at(1) == std::vector<std::uint32_t>{0}, "The base's own rows stay as they were");
check(read<std::uint32_t>(merged.resourceMeta, 8) == merged.resource.size() - read<std::uint32_t>(merged.resourceMeta, 0),
"The lookup size in the metadata matches the merged rows");
for (const auto& [key, values] : rows)
for (const auto value : values) check(value < 3, "No merged row points past the base's records");
if (failures) return 1;
std::cout << "shader lookup tests passed\n";
return 0;
}
+25 -2
View File
@@ -50,6 +50,10 @@ private:
// Oodle is the game's own module, so it is resolved once and never unloaded.
#ifdef _WIN32
// What Oodle does when one of its checks fails on what it is given: its default stops the
// process at a breakpoint (the launcher's mod scan, "EXCEPTION_BREAKPOINT" in oo2core,
// 2026-10-15). Not stopping, the call fails and the block is reported as unreadable.
int __cdecl oodle_assertion(const char*, int, const char*, const char*) { return 0; }
HMODULE oodle(const std::filesystem::path& gameRoot) {
static std::mutex mutex;
static HMODULE handle{};
@@ -74,6 +78,13 @@ HMODULE oodle(const std::filesystem::path& gameRoot) {
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!handle) throw std::runtime_error("Cannot load " + path_utf8(matches.back()) +
" (Windows error " + std::to_string(GetLastError()) + ")");
// (Only for a copy loaded here: the game's own, found above, keeps the game's handler.)
using Assertion = int(__cdecl*)(const char*, int, const char*, const char*);
#pragma warning(push)
#pragma warning(disable: 4191)
if (const auto set = reinterpret_cast<Assertion (__cdecl*)(Assertion)>(GetProcAddress(handle, "OodleCore_Plugins_SetAssertion")))
#pragma warning(pop)
set(&oodle_assertion);
return handle;
}
@@ -134,12 +145,24 @@ std::vector<std::byte> oodle_decode(std::span<const std::byte> input, std::size_
#ifdef _WIN32
using Function = std::int64_t(__cdecl*)(const void*, std::int64_t, void*, std::int64_t, int, int,
int, void*, std::int64_t, void*, void*, void*, std::int64_t, int);
using BufferSize = std::int64_t(__cdecl*)(int, std::int64_t, int);
const auto decode = oodle_function<Function>(gameRoot, "OodleLZ_Decompress");
std::vector<std::byte> output(size);
// A block can be corrupt, or not a block at all (read_manifest tries a payload as one when
// it does not read as a manifest), and its size is only what its header says. Oodle may
// then write past that size: the buffer it says such data needs (for any compressor) is
// given, and cut to the size afterwards. With exactly the size, it overwrote the heap
// (the launcher's mod scan crashing in malloc after an Oodle block, 2026-10-15).
constexpr int any_compressor = -1; // OodleLZ_Compressor_Invalid
constexpr std::int64_t block = 0x40000; // OODLELZ_BLOCK_LEN
const auto needed = oodle_function<BufferSize>(gameRoot, "OodleLZ_GetDecodeBufferSize")(
any_compressor, static_cast<std::int64_t>(size), 1);
const auto padded = std::max<std::int64_t>(needed, static_cast<std::int64_t>(size) + block);
std::vector<std::byte> output(static_cast<std::size_t>(padded));
if (decode(input.data(), static_cast<std::int64_t>(input.size()), output.data(),
static_cast<std::int64_t>(output.size()), 1, 0, 0, nullptr, 0, nullptr, nullptr,
static_cast<std::int64_t>(size), 1, 0, 0, nullptr, 0, nullptr, nullptr,
nullptr, 0, 3) != static_cast<std::int64_t>(size))
throw std::runtime_error("Oodle CAS block failed to decode");
output.resize(size);
return output;
#else
(void)input;
+29 -3
View File
@@ -1,6 +1,7 @@
#include "ebx_document.h"
#include <algorithm>
#include <cstring>
#include <limits>
#include <stdexcept>
#include <unordered_map>
@@ -292,7 +293,14 @@ std::vector<ReflectionTypeId> read_reflection(BinaryReader& reader,
const auto groupCount = static_cast<std::size_t>(nonnegative(reader, "reflection group"));
ensure_records(reader, chunk.end, groupCount, 12, "reflection group");
reader.skip(static_cast<std::ptrdiff_t>(groupCount * 12));
document.reflectionGroups.reserve(groupCount);
for (std::size_t index = 0; index < groupCount; ++index) {
ReflectionGroup group;
group.hash = reader.u32();
group.count = reader.u32();
group.first = reader.u32();
document.reflectionGroups.push_back(group);
}
const auto mappingCount = static_cast<std::size_t>(nonnegative(reader, "reflection mapping"));
ensure_records(reader, chunk.end, mappingCount, 8, "reflection mapping");
reader.skip(static_cast<std::ptrdiff_t>(mappingCount * 8));
@@ -356,7 +364,9 @@ void map_instances(Document& document,
class ValueReader final {
public:
ValueReader(const std::span<const std::byte> bytes, Document& document)
: bytes_(bytes), reader_(bytes), document_(document) {}
: bytes_(bytes), reader_(bytes), document_(document) {
for (const auto& record : document.arrays) arrayHashes_.emplace(record.offset, record.hash);
}
void read_instances() {
for (auto& instance : document_.instances) {
@@ -423,7 +433,19 @@ private:
}
const auto position = start + field.dataOffset;
if (position > document_.dataEnd) throw std::runtime_error("EBX field offset exceeds EBXD");
output.fields.push_back({descriptorIndex, field.name, read_field(field, position)});
auto value = read_field(field, position);
if (field.category() == FieldCategory::array) {
const auto* values = std::get_if<Value::Array>(&value.data);
if (values && !values->empty()) {
std::int32_t displacement{};
std::memcpy(&displacement, bytes_.data() + position, sizeof(displacement));
const auto target = static_cast<std::int64_t>(position) + displacement -
static_cast<std::int64_t>(document_.dataStart);
if (const auto found = arrayHashes_.find(target); found != arrayHashes_.end())
value.arrayHash = found->second;
}
}
output.fields.push_back({descriptorIndex, field.name, std::move(value)});
}
}
@@ -552,6 +574,8 @@ private:
}
std::span<const std::byte> bytes_;
std::unordered_map<std::int64_t, std::uint32_t> arrayHashes_; // record offset -> its hash
BinaryReader reader_;
Document& document_;
unsigned depth_{};
@@ -615,6 +639,8 @@ Document read_document(const std::span<const std::byte> bytes) {
result.hashedReflectionNames = reflection2 != nullptr;
result.reflectionData.assign(bytes.begin() + static_cast<std::ptrdiff_t>(reflectionChunk.begin),
bytes.begin() + static_cast<std::ptrdiff_t>(reflectionChunk.end));
result.payload.assign(bytes.begin() + static_cast<std::ptrdiff_t>(result.dataStart),
bytes.begin() + static_cast<std::ptrdiff_t>(result.dataEnd));
map_instances(result, fixup, reflectionIds);
ValueReader(bytes, result).read_instances();
std::ranges::sort(result.boxedValues, {}, &BoxedValueRecord::offset);
+16
View File
@@ -6,6 +6,7 @@
#include <cstdint>
#include <span>
#include <memory>
#include <optional>
#include <string>
#include <variant>
#include <vector>
@@ -95,6 +96,14 @@ struct ArrayRecord {
std::uint16_t classRef{};
};
// A 12-byte record of the RFL2 group table: one per array type the document uses, sorted by
// hash (first[i + 1] = first[i] + count[i]). Every array record's hash is one of these keys.
struct ReflectionGroup {
std::uint32_t hash{};
std::uint32_t count{};
std::uint32_t first{};
};
struct BoxedValueRecord {
std::uint32_t offset{};
std::uint32_t count{};
@@ -149,6 +158,9 @@ struct Value {
PointerReference, TypeReference, BoxedReference,
std::shared_ptr<struct Object>, Array>;
Storage data;
// An array's own record hash, kept when the document is written again; a new array has none
// and borrows one (ebx_writer.cpp, array_hash).
std::optional<std::uint32_t> arrayHash;
};
struct FieldValue {
@@ -189,6 +201,10 @@ struct Document {
std::vector<std::uint32_t> importOffsets;
std::vector<std::uint32_t> typeInfoOffsets;
std::vector<std::uint32_t> boxedValueSlots;
// The EBXD bytes the document was read from (offsets as in the fixup tables): what boxed
// values point at (arrays, strings) is carried from here when the document is written.
std::vector<std::byte> payload;
std::vector<ReflectionGroup> reflectionGroups;
[[nodiscard]] const InstanceRecord* root() const noexcept;
};
+137
View File
@@ -0,0 +1,137 @@
#include "ebx_fixup_repair.h"
#include <cstdint>
#include <cstring>
#include <set>
#include <stdexcept>
namespace dingosdk::frostbite::ebx {
namespace {
std::uint32_t u32_at(std::span<const std::byte> bytes, std::size_t at) {
if (at > bytes.size() || bytes.size() - at < 4) throw std::out_of_range("EBX read past its end");
std::uint32_t value{};
std::memcpy(&value, bytes.data() + at, 4);
return value;
}
void put_u32(std::vector<std::byte>& bytes, std::size_t at, std::uint32_t value) { std::memcpy(bytes.data() + at, &value, 4); }
// The fixup lists of an EBX, read from its EFIX chunk alone: this runs on every EBX a mod
// ships, and the rest of the document has nothing to say about where the fixups go.
struct Fixups {
std::size_t efix{}, efixSize{}; // the chunk's data and its size
std::size_t typeList{}; // where the type-reference count is
std::vector<std::uint32_t> instances, pointers, resources, imports, types, boxed;
};
std::optional<Fixups> read_fixups(std::span<const std::byte> ebx) {
if (ebx.size() < 12 || std::memcmp(ebx.data(), "RIFF", 4) != 0) return std::nullopt;
Fixups out;
const auto riffEnd = std::size_t{u32_at(ebx, 4)} + 8;
if (riffEnd > ebx.size()) return std::nullopt;
for (std::size_t at = 12; at + 8 <= riffEnd;) {
const auto size = std::size_t{u32_at(ebx, at + 4)};
if (std::memcmp(ebx.data() + at, "EFIX", 4) == 0) {
out.efix = at + 8;
out.efixSize = size;
break;
}
at += 8 + size + (size & 1);
}
const auto end = out.efix + out.efixSize;
if (!out.efix || end > riffEnd) return std::nullopt;
std::size_t at = out.efix + 16; // the file guid
const auto count = [&](std::size_t width) {
const auto value = std::size_t{u32_at(ebx, at)};
at += 4;
if (value > (end - at) / width) throw std::out_of_range("EFIX list past its chunk");
return value;
};
const auto list = [&](std::vector<std::uint32_t>& into) {
into.resize(count(4));
for (auto& value : into) { value = u32_at(ebx, at); at += 4; }
};
at += 16 * count(16); // types
at += 4 * count(4); // signatures
at += 4; // exported instances
list(out.instances);
list(out.pointers);
list(out.resources);
at += 32 * count(32); // imports
list(out.imports);
out.typeList = at;
list(out.types);
if (end - at < 12) return std::nullopt;
at += 12; // the array, boxed-value and string sections
if (at < end) list(out.boxed);
if (at != end) return std::nullopt;
return out;
}
// Which type-reference entries collide with another fixup's slot, or repeat.
std::set<std::size_t> colliding(const Fixups& fixups) {
struct Range { std::uint64_t begin, end; };
std::vector<Range> others;
const auto add = [&](std::uint64_t begin, std::uint64_t size) { others.push_back({begin, begin + size}); };
for (const auto offset : fixups.pointers) add(offset, 8);
for (const auto offset : fixups.resources) add(offset, 8);
for (const auto offset : fixups.imports) add(offset, 8);
for (const auto offset : fixups.boxed) add(std::uint64_t{offset} + 8, 8);
for (const auto offset : fixups.instances) add(offset, 16);
std::set<std::size_t> bad;
std::set<std::uint32_t> seen;
for (std::size_t index = 0; index < fixups.types.size(); ++index) {
const std::uint64_t offset = fixups.types[index];
if (!seen.insert(fixups.types[index]).second) {
bad.insert(index);
continue;
}
for (const auto& other : others)
if (offset < other.end && other.begin < offset + 8) {
bad.insert(index);
break;
}
}
return bad;
}
} // namespace
std::optional<FixupRepair> repair_type_fixups(std::span<const std::byte> ebx) {
std::optional<Fixups> fixups;
try {
fixups = read_fixups(ebx);
} catch (const std::exception&) {
return std::nullopt;
}
if (!fixups) return std::nullopt;
const auto bad = colliding(*fixups);
if (bad.empty()) return std::nullopt;
// The type-reference list is the only thing that changes length. EBXD comes ahead of EFIX,
// so the data's alignment and every offset into it stay as they are.
FixupRepair out;
out.removed = bad.size();
const auto list = fixups->typeList;
out.bytes.assign(ebx.begin(), ebx.begin() + static_cast<std::ptrdiff_t>(list + 4));
for (std::size_t index = 0; index < fixups->types.size(); ++index) {
if (bad.contains(index)) continue;
const auto at = out.bytes.size();
out.bytes.resize(at + 4);
put_u32(out.bytes, at, fixups->types[index]);
}
const auto rest = list + 4 + 4 * fixups->types.size();
out.bytes.insert(out.bytes.end(), ebx.begin() + static_cast<std::ptrdiff_t>(rest), ebx.end());
const auto shrink = static_cast<std::uint32_t>(4 * bad.size());
put_u32(out.bytes, list, static_cast<std::uint32_t>(fixups->types.size() - bad.size()));
put_u32(out.bytes, fixups->efix - 4, static_cast<std::uint32_t>(fixups->efixSize) - shrink); // the chunk's size (its padding is unchanged)
put_u32(out.bytes, 4, u32_at(ebx, 4) - shrink); // the RIFF's
// What was written reads back, without the collisions.
const auto again = read_fixups(out.bytes);
if (!again || !colliding(*again).empty() || again->types.size() != fixups->types.size() - bad.size())
throw std::runtime_error("EBX type-reference repair did not read back");
return out;
}
} // namespace dingosdk::frostbite::ebx
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <cstddef>
#include <optional>
#include <span>
#include <vector>
// An EBX's type-reference fixups that would crash the game's load. The fast-path partition load
// (Skate.exe 0x3def1c0) applies the other fixups first and then reads each type-reference slot as
// a type index; a type reference listed on a slot another fixup also writes (an import, a pointer,
// a resource reference, an instance's header, a boxed reference's pointer half), or listed twice,
// reads a pointer as an index and the game crashes (Skate.exe+0x3def285 / +0x3def262: Master
// Chief and TASM2 costume presets, 2026-10-17). The game's own files never do this. Removing those
// entries from the type-reference list leaves the slot to the fixup that owns it.
namespace dingosdk::frostbite::ebx {
struct FixupRepair {
std::vector<std::byte> bytes; // the document with those entries removed
std::size_t removed{};
};
// The document repaired, or nothing when it needs none (or is not one this reads).
[[nodiscard]] std::optional<FixupRepair> repair_type_fixups(std::span<const std::byte> ebx);
} // namespace dingosdk::frostbite::ebx
+180 -17
View File
@@ -6,6 +6,8 @@
#include <cmath>
#include <cstring>
#include <limits>
#include <map>
#include <optional>
#include <ranges>
#include <stdexcept>
#include <string_view>
@@ -85,7 +87,8 @@ std::size_t scalar_size(const FieldType type) {
class Builder final {
public:
explicit Builder(const Document& document) : document_(document) {}
Builder(const Document& document, const bool refuseNewArrayHashes)
: document_(document), refuse_(refuseNewArrayHashes) {}
std::vector<std::byte> run() {
validate();
@@ -106,6 +109,8 @@ private:
std::uint32_t slot{};
const Value::Array* values{};
std::size_t descriptor{};
std::optional<std::uint32_t> hash;
std::int32_t holder{-1}; // type of the object holding the field
bool emitted{};
};
@@ -208,7 +213,8 @@ private:
static const Value::Array empty;
const auto* array = value ? std::get_if<Value::Array>(&value->data) : nullptr;
arraysPending_.push_back({checked_u32(position, "EBX array slot"), array ? array : &empty,
descriptorIndex});
descriptorIndex, value ? value->arrayHash : std::nullopt,
object ? object->descriptor : static_cast<std::int32_t>(typeIndex)});
} else if (field.type() == FieldType::structure) {
if (field.classRef >= document_.types.size()) throw std::runtime_error("EBX struct type is invalid");
const auto nestedStart = align_up(position, std::max<std::size_t>(document_.types[field.classRef].alignment, 1));
@@ -352,6 +358,44 @@ private:
return field.nameHash;
}
// A new array takes the hash of an array the document read for the same field of the same type;
// failing that it borrows (array_hash), or is refused (see write_document).
std::uint32_t new_array_hash(const PendingArray& pending, const FieldDescriptor& field) {
if (!knownWalked_) {
knownWalked_ = true;
for (const auto& instance : document_.instances)
if (instance.object) collect_hashes(*instance.object);
}
const auto found = knownHashes_.find({pending.holder, pending.descriptor});
if (refuse_) {
if (found == knownHashes_.end())
throw std::runtime_error("EBX new array " + std::string(field.name) +
" has no array of its field to take a hash from");
if (document_.hashedReflectionNames &&
std::ranges::none_of(document_.reflectionGroups,
[&](const ReflectionGroup& group) { return group.hash == found->second; }))
throw std::runtime_error("EBX array hash of " + std::string(field.name) +
" is not in the reflection's group table");
}
return found != knownHashes_.end() ? found->second : array_hash(field);
}
void collect_hashes(const Object& object) {
for (const auto& field : object.fields) {
if (field.value.arrayHash)
knownHashes_.emplace(std::pair{object.descriptor, field.descriptor}, *field.value.arrayHash);
collect_value(field.value);
}
}
void collect_value(const Value& value) {
if (const auto* nested = std::get_if<std::shared_ptr<Object>>(&value.data)) {
if (*nested) collect_hashes(**nested);
} else if (const auto* array = std::get_if<Value::Array>(&value.data)) {
for (const auto& element : *array) collect_value(element);
}
}
void write_arrays() {
arraysOffset_ = checked_u32(align_up(payload_.size(), 16), "EBX array section");
grow(static_cast<std::size_t>(arraysOffset_) + 32);
@@ -359,6 +403,124 @@ private:
for (std::size_t index = 0; index < arraysPending_.size(); ++index) {
if (!arraysPending_[index].emitted) emit_array(index);
}
carry_boxed_arrays();
}
// What a boxed value points at is not in the document's values: its bytes are carried as they
// were, so the arrays and strings its pointers reach are carried with them and the pointers
// moved to the new places. Without this a rewrite dropped them (seen on the game's own
// level roots: a boxed debug string and about 1 KB of arrays).
enum class Target { array, string, instance, boxed, empty, unknown };
std::pair<Target, std::uint32_t> old_target(const std::uint32_t slot) const {
const auto& old = document_.payload;
std::int32_t displacement{};
std::memcpy(&displacement, old.data() + slot, sizeof(displacement));
const auto target = static_cast<std::uint32_t>(static_cast<std::int64_t>(slot) + displacement);
if (oldArrays_.contains(target)) return {Target::array, target};
if (document_.stringsOffset <= target && target < old.size()) return {Target::string, target};
if (const auto found = oldInstances_.find(target); found != oldInstances_.end())
return {Target::instance, found->second};
if (document_.boxedValuesOffset <= target && target < document_.stringsOffset) return {Target::boxed, target};
if (target >= 4 && target <= old.size()) {
std::uint32_t count{};
std::memcpy(&count, old.data() + target - 4, sizeof(count));
if (count == 0) return {Target::empty, target};
}
return {Target::unknown, target};
}
static std::vector<std::uint32_t> old_slots(const std::uint32_t start, const std::size_t length,
std::vector<std::uint32_t> offsets) {
std::ranges::sort(offsets);
offsets.erase(std::unique(offsets.begin(), offsets.end()), offsets.end());
std::vector<std::uint32_t> out;
for (const auto value : offsets)
if (value >= start && value - start < length) out.push_back(value);
return out;
}
void carry_boxed_arrays() {
if (document_.payload.empty()) return;
for (const auto& record : document_.arrays) oldArrays_.emplace(record.offset, record);
for (std::size_t index = 0; index < document_.instances.size(); ++index)
oldInstances_.emplace(document_.instances[index].dataOffset, static_cast<std::uint32_t>(index));
auto records = document_.boxedValues;
std::ranges::sort(records, {}, &BoxedValueRecord::offset);
for (const auto& source : records)
for (const auto slot : old_slots(source.offset, source.rawBytes.size(), document_.pointerOffsets)) {
const auto [kind, key] = old_target(slot);
if (kind == Target::array) carry_array(oldArrays_.at(key));
}
}
std::pair<std::size_t, std::size_t> array_element(const ArrayRecord& record) const {
FieldDescriptor probe;
probe.encodedType = static_cast<std::uint16_t>(record.encodedType >> 1);
if (probe.type() == FieldType::structure) {
const auto slot = record.classRef;
if (slot < document_.fixupTypeGuids.size() && slot < document_.fixupTypeSignatures.size())
for (const auto& type : document_.types)
if (type.guid == document_.fixupTypeGuids[slot] && type.signature == document_.fixupTypeSignatures[slot])
return {std::max<std::size_t>(type.size, 1), std::max<std::size_t>(type.alignment, 1)};
throw std::runtime_error("EBX array record has an unknown struct type");
}
const auto stride = scalar_size(probe.type());
return {stride, std::max<std::size_t>(std::min<std::size_t>(stride, 8), 1)};
}
void carry_array(const ArrayRecord& record) {
if (carried_.contains(record.offset)) return;
const auto [stride, alignment] = array_element(record);
const auto length = stride * record.count;
if (record.offset + length > document_.payload.size()) throw std::runtime_error("EBX carried array exceeds EBXD");
const auto data = align_up(payload_.size() + 4, alignment);
put_number(data - 4, record.count);
put(data, std::span(document_.payload).subspan(record.offset, length));
const auto placed = checked_u32(data, "EBX array data");
carried_[record.offset] = placed;
arrays_.push_back({placed, record.count, record.hash, record.encodedType, record.classRef});
relocate(record.offset, length, data);
}
void relocate(const std::uint32_t oldStart, const std::size_t length, const std::size_t newStart) {
const auto moved = [&](const std::uint32_t old) {
return checked_u32(newStart + (old - oldStart), "EBX carried relocation");
};
for (const auto old : old_slots(oldStart, length, document_.resourceRefOffsets)) resourceOffsets_.push_back(moved(old));
for (const auto old : old_slots(oldStart, length, document_.importOffsets)) importOffsets_.push_back(moved(old));
for (const auto old : old_slots(oldStart, length, document_.typeInfoOffsets)) typeInfoOffsets_.push_back(moved(old));
for (const auto old : old_slots(oldStart, length, document_.pointerOffsets)) {
const auto slot = moved(old);
pointerOffsets_.push_back(slot);
if (document_.payload.empty()) continue;
const auto [kind, key] = old_target(old);
switch (kind) {
case Target::array:
carry_array(oldArrays_.at(key));
put_number(slot, static_cast<std::uint32_t>(carried_.at(key) - slot));
break;
case Target::string: {
const auto& source = document_.payload;
auto end = static_cast<std::size_t>(key);
while (end < source.size() && source[end] != std::byte{0}) ++end;
stringsPending_.push_back({slot, std::string(reinterpret_cast<const char*>(source.data()) + key, end - key)});
break;
}
case Target::instance:
put_number(slot, static_cast<std::uint32_t>(instanceOffsets_.at(key) - slot));
break;
case Target::empty:
put_number(slot, static_cast<std::uint32_t>(emptyArrayTarget_ - slot));
break;
case Target::boxed:
boxedTargets_.emplace_back(slot, key);
break;
case Target::unknown:
break;
}
}
}
void emit_array(const std::size_t index) {
@@ -387,8 +549,11 @@ private:
write_scalar(field, elementStart, &value);
}
}
// An array read from a record keeps that record's hash; a new one borrows (array_hash).
auto hash = arraysPending_[index].hash;
if (!hash) hash = new_array_hash(arraysPending_[index], field);
arrays_.push_back({checked_u32(data, "EBX array data"), checked_u32(values->size(), "EBX array count"),
array_hash(field), static_cast<std::uint16_t>(field.encodedType << 1),
*hash, static_cast<std::uint16_t>(field.encodedType << 1),
field.type() == FieldType::structure
? fixup_slot_for_descriptor(field.classRef)
: static_cast<std::uint16_t>(0xFFFF)});
@@ -407,23 +572,14 @@ private:
if (source.offset < document_.boxedValuesOffset) throw std::runtime_error("EBX boxed record precedes its section");
const auto offset = static_cast<std::size_t>(boxedOffset_) + source.offset - document_.boxedValuesOffset;
put(offset, source.rawBytes);
const auto relocate = [&](const std::vector<std::uint32_t>& sourceOffsets,
std::vector<std::uint32_t>& destinationOffsets) {
for (const auto old : sourceOffsets) {
if (old < source.offset || old - source.offset >= source.rawBytes.size()) continue;
destinationOffsets.push_back(checked_u32(offset + old - source.offset,
"EBX boxed relocation"));
}
};
relocate(document_.pointerOffsets, pointerOffsets_);
relocate(document_.resourceRefOffsets, resourceOffsets_);
relocate(document_.importOffsets, importOffsets_);
relocate(document_.typeInfoOffsets, typeInfoOffsets_);
relocate(source.offset, source.rawBytes.size(), offset);
moved[source.offset] = checked_u32(offset, "EBX boxed value");
auto authored = source;
authored.offset = checked_u32(offset, "EBX boxed value");
boxed_.push_back(std::move(authored));
}
for (const auto& [slot, target] : boxedTargets_)
put_number(slot, static_cast<std::uint32_t>(boxedOffset_ + (target - document_.boxedValuesOffset) - slot));
for (const auto& pending : boxesPending_) {
put_number(pending.slot, pending.value.encodedType);
put_number(static_cast<std::size_t>(pending.slot) + 4, std::uint32_t{});
@@ -588,6 +744,13 @@ private:
std::uint32_t arraysOffset_{};
std::uint32_t boxedOffset_{};
std::uint32_t emptyArrayTarget_{};
std::unordered_map<std::uint32_t, ArrayRecord> oldArrays_;
std::unordered_map<std::uint32_t, std::uint32_t> oldInstances_;
std::unordered_map<std::uint32_t, std::uint32_t> carried_;
std::vector<std::pair<std::uint32_t, std::uint32_t>> boxedTargets_;
bool refuse_{};
bool knownWalked_{};
std::map<std::pair<std::int32_t, std::size_t>, std::uint32_t> knownHashes_; // (holder, field) -> hash
};
}
@@ -611,8 +774,8 @@ std::uint16_t ensure_fixup_type(Document& document, const std::int32_t descripto
return static_cast<std::uint16_t>(count);
}
std::vector<std::byte> write_document(const Document& document) {
return Builder(document).run();
std::vector<std::byte> write_document(const Document& document, const bool refuseNewArrayHashes) {
return Builder(document, refuseNewArrayHashes).run();
}
}
+5 -1
View File
@@ -9,6 +9,10 @@ namespace dingosdk::frostbite::ebx {
[[nodiscard]] std::uint16_t ensure_fixup_type(Document& document, std::int32_t descriptor);
[[nodiscard]] std::vector<std::byte> write_document(const Document& document);
// A new array (one no record was read for) takes the hash of an array the document read for the
// same field of the same type. Without one it borrows another array's hash (the merge's way), or,
// with refuseNewArrayHashes (the map builder's), the write is refused: the RFL2 group table would
// need a new entry. A hash that is not a key of that table is refused the same way.
[[nodiscard]] std::vector<std::byte> write_document(const Document& document, bool refuseNewArrayHashes = false);
}
+113
View File
@@ -1,4 +1,5 @@
#include "mesh_set.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <stdexcept>
@@ -131,6 +132,118 @@ std::vector<std::array<float, 3>> read_mesh_positions(std::span<const std::byte>
return result;
}
namespace {
// The two layouts check_mesh_lod reads: the skinned one above, and the static one world meshes
// have (192, 176, 384, 0), whose levels of detail are laid out the same. A static mesh's sections
// are 384 or 416 bytes, and nothing found yet says which: check_mesh_lod takes the size under
// which every section reads as one.
struct Layout {
bool skinned{};
};
Layout layout_of(const Reader& r) {
const auto first = r.at<std::uint32_t>(0), second = r.at<std::uint32_t>(4), third = r.at<std::uint32_t>(8);
if (first == 208 && second == 188 && third == 384) return {true};
if (first == 192 && second == 176 && third == 384) return {false};
require(false, "its layout is not one this reader knows");
return {};
}
// Whether every section, `stride` bytes apart, reads as one: a triangle list whose indices are in
// the index buffer and whose vertices start in the vertex buffer.
bool sections_read(const Reader& r, std::size_t table, std::uint32_t sections, std::size_t stride, std::size_t index_size,
std::size_t vertex_size, std::size_t index_total) {
for (std::uint32_t section = 0; section < sections; ++section) {
const auto s = table + section * stride;
if (s + stride > r.data.size()) return false;
const std::size_t triangles = r.at<std::uint32_t>(s + triangle_count);
if (!triangles) continue;
if (r.at<std::uint8_t>(s + primitive) != triangle_list || r.at<std::uint32_t>(s + vertex_start) >= vertex_size ||
std::size_t{r.at<std::uint32_t>(s + first_index)} * index_size + triangles * 3 * index_size > index_total)
return false;
}
return true;
}
}
std::vector<std::string> check_mesh_lod(std::span<const std::byte> resource, std::size_t lod,
std::span<const std::byte> geometry, std::uint32_t* format_out) {
const Reader r{resource};
const auto shape = layout_of(r);
require(lod < r.at<std::uint16_t>(lod_count), "it has no such level of detail");
const auto at = r.pointer(lod_table + lod * 8);
const std::size_t vertex_size = r.at<std::uint32_t>(at + vertex_bytes), index_size_total = r.at<std::uint32_t>(at + index_bytes);
const auto format = r.at<std::uint32_t>(at + index_format);
if (format_out) *format_out = format;
const std::size_t index_size = format == sixteen_bit_indices ? 2 : 4;
std::vector<std::string> problems;
const auto say = [&](std::string text) { if (problems.size() < 8) problems.push_back(std::move(text)); };
if (geometry.size() < vertex_size + index_size_total)
say("the geometry chunk holds " + std::to_string(geometry.size()) + " bytes of the " +
std::to_string(vertex_size + index_size_total) + " its buffers need");
const auto sections = r.at<std::uint32_t>(at + section_count);
require(sections <= 4096, "it has too many sections");
if (!sections) return problems;
const auto table = r.pointer(at + section_table);
std::size_t stride = section_size;
if (!shape.skinned) {
if (sections_read(r, table, sections, 384, index_size, vertex_size, index_size_total)) stride = 384;
else if (sections_read(r, table, sections, 416, index_size, vertex_size, index_size_total)) stride = 416;
else say("its sections read as neither 384 nor 416 bytes apart: one of them is out of its buffers");
}
for (std::uint32_t section = 0; section < sections; ++section) {
const auto s = table + section * stride;
const auto name = "section " + std::to_string(section);
const auto type = r.at<std::uint8_t>(s + primitive);
const std::size_t count = r.at<std::uint32_t>(s + vertex_count), triangles = r.at<std::uint32_t>(s + triangle_count);
if (!triangles) continue;
if (type != triangle_list) { say(name + " is primitive type " + std::to_string(type) + ", not a triangle list"); continue; }
if (!count) { say(name + " has " + std::to_string(triangles) + " triangles and no vertices"); continue; }
const std::size_t first = r.at<std::uint32_t>(s + vertex_start);
if (first >= vertex_size) say(name + "'s vertices start at byte " + std::to_string(first) + " of a " + std::to_string(vertex_size) + "-byte vertex buffer");
if (shape.skinned) {
// (Where its vertices end: each stream's follow the one before.)
const auto d = s + declaration;
const auto streams = r.at<std::uint8_t>(d + stream_total);
std::size_t end = first;
for (std::size_t stream = 0; stream < streams && stream < 16; ++stream)
end += std::size_t{r.at<std::uint8_t>(d + stream_table + stream * 2)} * count;
if (end > vertex_size)
say(name + "'s vertices end at byte " + std::to_string(end) + " of a " + std::to_string(vertex_size) + "-byte vertex buffer");
}
const std::size_t start = std::size_t{r.at<std::uint32_t>(s + first_index)} * index_size;
const std::size_t length = triangles * 3 * index_size;
if (start + length > index_size_total) {
say(name + "'s indices end at byte " + std::to_string(start + length) + " of a " + std::to_string(index_size_total) + "-byte index buffer");
continue;
}
const auto base = vertex_size + start;
if (base + length > geometry.size()) continue;
std::size_t outside{}, largest{};
for (std::size_t i = 0; i < triangles * 3; ++i) {
std::size_t index{};
std::memcpy(&index, geometry.data() + base + i * index_size, index_size);
if (index >= count) ++outside, largest = std::max(largest, index);
}
if (outside)
say(name + ": " + std::to_string(outside) + " of " + std::to_string(triangles * 3) + " indices name a vertex outside its " +
std::to_string(count) + " (the largest " + std::to_string(largest) + ")");
}
return problems;
}
MeshLod any_mesh_lod_geometry(std::span<const std::byte> resource, std::size_t lod, std::size_t* lods) {
const Reader r{resource};
(void)layout_of(r);
const std::size_t total = r.at<std::uint16_t>(lod_count);
if (lods) *lods = total;
require(lod < total, "it has no such level of detail");
const auto at = r.pointer(lod_table + lod * 8);
MeshLod result;
result.chunk = r.at<Guid>(at + geometry_chunk);
result.vertex_bytes = r.at<std::uint32_t>(at + vertex_bytes);
result.index_bytes = r.at<std::uint32_t>(at + index_bytes);
return result;
}
SkinnedMesh read_skinned_mesh(std::span<const std::byte> resource, std::size_t lod, std::span<const std::byte> geometry) {
const auto layout = read_mesh_lod(resource, lod);
const Reader r{resource}, g{geometry};
+10
View File
@@ -7,6 +7,7 @@
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace dingosdk::frostbite {
@@ -28,6 +29,15 @@ struct MeshLod {
[[nodiscard]] std::vector<std::array<float, 3>> read_mesh_positions(std::span<const std::byte> resource, std::size_t lod,
std::span<const std::byte> geometry);
// A level of detail's geometry chunk and buffer sizes, of either layout check_mesh_lod reads (the
// skinned one, and world meshes' static one); `lods` gets how many levels there are.
[[nodiscard]] MeshLod any_mesh_lod_geometry(std::span<const std::byte> resource, std::size_t lod, std::size_t* lods = nullptr);
// What is wrong with a level of detail's geometry as the GPU would read it, a skinned or a static
// MeshSet: a section whose vertices or indices run past their buffer, or a triangle that
// names a vertex outside its section. Empty when it is sound. `index_format` gets the level's
// index format (33: 16-bit; anything else is read as 32-bit). Throws for another layout.
[[nodiscard]] std::vector<std::string> check_mesh_lod(std::span<const std::byte> resource, std::size_t lod,
std::span<const std::byte> geometry, std::uint32_t* index_format = nullptr);
struct SkinnedVertex {
std::array<float, 3> position{}; // model space
std::array<std::uint16_t, 8> bones{}; // skeleton bones
+18 -159
View File
@@ -1,9 +1,7 @@
#include "shader_lookup.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <limits>
#include <map>
#include <stdexcept>
#include <string>
@@ -80,176 +78,37 @@ TextureRows read_texture_rows(const Table& table) {
} // namespace
namespace {
constexpr std::size_t programStride = 168;
struct ProgramBlob {
Table source;
std::span<const std::byte> payload;
std::uint64_t records{};
std::uint32_t count{};
std::vector<std::uint32_t> relocations;
};
ProgramBlob program_blob(const Table& source) {
const auto end = lookup_offset(source);
const auto size = read<std::uint32_t>(source.resourceMeta, 0);
if ((end - size) % 4) throw std::runtime_error("Program relocations are not aligned");
ProgramBlob blob{source, source.resource.first(size)};
blob.count = read<std::uint32_t>(blob.payload, 0);
blob.records = read<std::uint64_t>(blob.payload, 4);
if (blob.records > size || blob.count > (size - blob.records) / programStride)
throw std::runtime_error("Program records exceed their payload");
for (auto at = std::size_t{size}; at < end; at += 4) {
const auto pointer = read<std::uint32_t>(source.resource, at);
if (read<std::uint64_t>(blob.payload, pointer) > size)
throw std::runtime_error("Program relocation points outside its payload");
blob.relocations.push_back(pointer);
}
return blob;
}
struct ProgramArray { std::size_t pointer, stride; };
constexpr std::array programArrays{
ProgramArray{0x10, 16}, ProgramArray{0x1c, 8}, ProgramArray{0x28, 8}, ProgramArray{0x34, 16},
ProgramArray{0x40, 6}, ProgramArray{0x4c, 8}, ProgramArray{0x58, 2},
ProgramArray{0x64, 8}, ProgramArray{0x70, 8}, ProgramArray{0x80, 16}, ProgramArray{0x8c, 32}
};
std::size_t record_at(const ProgramBlob& blob, std::uint32_t index) {
if (index >= blob.count) throw std::runtime_error("Material references an absent shader program");
return static_cast<std::size_t>(blob.records) + index * programStride;
}
std::span<const std::byte> program_array(const ProgramBlob& blob, std::size_t record, ProgramArray array) {
const auto count = read<std::uint32_t>(blob.payload, record + array.pointer - 4);
const auto pointer = read<std::uint64_t>(blob.payload, record + array.pointer);
if (pointer > blob.payload.size() || count > (blob.payload.size() - pointer) / array.stride)
throw std::runtime_error("Shader program array exceeds its payload");
return blob.payload.subspan(static_cast<std::size_t>(pointer), count * array.stride);
}
bool same_program(const ProgramBlob& a, std::uint32_t ai, const ProgramBlob& b, std::uint32_t bi) {
const auto aa = record_at(a, ai), ba = record_at(b, bi);
for (std::size_t at = 0; at < programStride; ++at) {
if (std::ranges::any_of(programArrays, [&](auto array) {
return at >= array.pointer && at < array.pointer + 8; })) continue;
if (a.payload[aa + at] != b.payload[ba + at]) return false;
}
for (const auto array : programArrays)
if (!std::ranges::equal(program_array(a, aa, array), program_array(b, ba, array))) return false;
return true;
}
std::uint32_t native_offset(std::size_t offset) {
if (offset > std::numeric_limits<std::uint32_t>::max())
throw std::runtime_error("Merged program database exceeds the native address range");
return static_cast<std::uint32_t>(offset);
}
} // namespace
MergedTable merge_program_lookup(const Table& base, const std::span<const Table> edits) {
std::vector<ProgramBlob> blobs{program_blob(base)};
for (const auto& edit : edits) blobs.push_back(program_blob(edit));
struct Reference { std::size_t blob; std::uint32_t program; };
std::map<std::uint64_t, std::vector<Reference>> references;
const auto start = lookup_offset(base);
const auto records = read<std::uint32_t>(base.resource, 0); // the base blob's record count
auto rows = read_program_rows(base);
for (const auto& [key, values] : rows) for (const auto index : values) {
record_at(blobs.front(), index);
references[key].push_back({0, index});
}
const auto baseRows = rows;
MergedTable merged;
for (std::size_t b = 1; b < blobs.size(); ++b) {
for (const auto& [key, values] : read_program_rows(blobs[b].source)) {
for (const auto index : values) record_at(blobs[b], index);
for (const auto& edit : edits) {
for (const auto& [key, values] : read_program_rows(edit)) {
if (baseRows.contains(key)) continue;
if (const auto previous = references.find(key); previous != references.end()) {
bool same = previous->second.size() == values.size();
for (std::size_t i = 0; same && i < values.size(); ++i) {
const auto ref = previous->second[i];
same = same_program(blobs[ref.blob], ref.program, blobs[b], values[i]);
}
if (!same) ++merged.conflicts;
if (std::any_of(values.begin(), values.end(), [&](std::uint32_t index) { return index >= records; })) {
++merged.outside;
continue;
}
for (const auto index : values) references[key].push_back({b, index});
++merged.added;
const auto [row, inserted] = rows.try_emplace(key, values);
if (inserted) ++merged.added;
else if (row->second != values) ++merged.conflicts;
}
}
// Preserve the exact native prefix when edits only add aliases to stock programs.
// Private programs need their own records and relocated backing data as well.
using Source = std::pair<std::size_t, std::uint32_t>;
std::map<Source, std::uint32_t> remapped;
std::vector<Source> additions;
for (const auto& [key, refs] : references) {
auto& row = rows[key];
row.clear();
for (const auto ref : refs) {
auto index = ref.program;
if (ref.blob && (index >= blobs.front().count ||
!same_program(blobs.front(), index, blobs[ref.blob], index))) {
const Source source{ref.blob, index};
auto [entry, inserted] = remapped.try_emplace(source,
native_offset(std::size_t{blobs.front().count} + additions.size()));
if (inserted) additions.push_back(source);
index = entry->second;
}
row.push_back(index);
}
}
merged.resourceMeta.assign(base.resourceMeta.begin(), base.resourceMeta.end());
if (additions.empty()) {
merged.resource.assign(base.resource.begin(), base.resource.begin() + lookup_offset(base));
} else {
merged.resource.assign(blobs.front().payload.begin(), blobs.front().payload.end());
auto relocations = blobs.front().relocations;
std::map<std::size_t, std::size_t> offsets;
for (const auto& [b, index] : additions) {
if (offsets.contains(b)) continue;
merged.resource.resize((merged.resource.size() + 15) & ~std::size_t{15});
const auto offset = merged.resource.size();
offsets[b] = offset;
const auto& blob = blobs[b];
// Copy the complete backing graph, including opaque resource bindings.
// This keeps runtime-mutated GUID arrays separate across mods.
merged.resource.insert(merged.resource.end(), blob.payload.begin(), blob.payload.end());
for (const auto pointer : blob.relocations) {
const auto destination = native_offset(offset + pointer);
const auto target = read<std::uint64_t>(blob.payload, pointer) + offset;
std::memcpy(merged.resource.data() + destination, &target, 8);
relocations.push_back(destination);
}
}
merged.resource.resize((merged.resource.size() + 7) & ~std::size_t{7});
const auto table = static_cast<std::uint64_t>(merged.resource.size());
const auto count = native_offset(std::size_t{blobs.front().count} + additions.size());
merged.resource.resize(native_offset(static_cast<std::size_t>(table) + std::size_t{count} * programStride));
const auto copy_record = [&](const ProgramBlob& blob, std::uint32_t index,
std::size_t offset, std::uint32_t destination) {
const auto source = record_at(blob, index);
const auto target = static_cast<std::size_t>(table) + destination * programStride;
std::memcpy(merged.resource.data() + target, merged.resource.data() + offset + source, programStride);
for (const auto pointer : blob.relocations)
if (pointer >= source && pointer < source + programStride)
relocations.push_back(native_offset(target + pointer - source));
};
for (std::uint32_t i = 0; i < blobs.front().count; ++i) copy_record(blobs.front(), i, 0, i);
for (const auto& source : additions)
copy_record(blobs[source.first], source.second, offsets.at(source.first), remapped.at(source));
std::memcpy(merged.resource.data(), &count, 4);
std::memcpy(merged.resource.data() + 4, &table, 8);
relocations.push_back(4);
std::ranges::sort(relocations);
relocations.erase(std::ranges::unique(relocations).begin(), relocations.end());
const auto size = native_offset(merged.resource.size());
for (const auto pointer : relocations) append(merged.resource, pointer);
const auto relocationSize = native_offset(relocations.size() * 4);
std::memcpy(merged.resourceMeta.data(), &size, 4);
std::memcpy(merged.resourceMeta.data() + 4, &relocationSize, 4);
}
const auto lookup = merged.resource.size();
append(merged.resource, native_offset(rows.size()));
merged.resource.assign(base.resource.begin(),
base.resource.begin() + static_cast<std::ptrdiff_t>(start));
append(merged.resource, static_cast<std::uint32_t>(rows.size()));
for (const auto& [key, values] : rows) {
append(merged.resource, key);
append(merged.resource, native_offset(values.size()));
append(merged.resource, static_cast<std::uint32_t>(values.size()));
for (const auto value : values) append(merged.resource, value);
}
const auto size = native_offset(merged.resource.size() - lookup);
std::memcpy(merged.resourceMeta.data() + 8, &size, 4);
merged.resourceMeta.assign(base.resourceMeta.begin(), base.resourceMeta.end());
const auto size = static_cast<std::uint32_t>(merged.resource.size() - start);
std::memcpy(merged.resourceMeta.data() + 8, &size, sizeof(size));
return merged;
}
+14 -6
View File
@@ -1,8 +1,9 @@
#pragma once
// Shader lookup tables carry material rows and compiled program records.
// Mods can alias stock programs or add private programs with their own
// permutations and backing arrays; both must survive a merge.
// The two shader lookup tables a level superbundle carries. A map mod adds a
// row per material it introduces, aliasing a shader program and texture set the
// base already ships, so two mods' tables differ from the base only by the rows
// each added and can be combined exactly.
#include <cstddef>
#include <cstdint>
@@ -24,11 +25,18 @@ struct MergedTable {
std::vector<std::byte> resourceMeta;
std::size_t added{}; // rows taken from the edits
std::size_t conflicts{}; // rows two edits disagreed on; the first won
std::size_t outside{}; // rows left out: they point past the base's programs
};
// `resourceMeta` is 16 bytes: program size, relocation size, lookup size.
// Private program records and their relocated backing data are copied from
// each edit, and material indices are remapped into the combined table.
// `resourceMeta` is 16 bytes: program size, relocation size, lookup size. The
// program blob and its relocations are identical across copies because a mod
// only ever points new keys at programs that are already there. The blob starts
// with its record count (u32), and every value of a lookup row is a record's
// index: the game takes it without a bounds check (an index past the end puts
// a pointer outside the database into its shader registry, Skate.exe 0x3f72280),
// so an added row with an index the base's blob does not have is left out
// (`outside`): a mod that brought programs of its own cannot have them merged
// onto the base's.
[[nodiscard]] MergedTable merge_program_lookup(const Table& base, std::span<const Table> edits);
[[nodiscard]] MergedTable merge_texture_lookup(const Table& base, std::span<const Table> edits);
@@ -0,0 +1,334 @@
#include <Windows.h>
#include <winhttp.h>
#include "../https_download.h"
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <mutex>
#include <string>
#include <utility>
namespace fake_http {
enum class Stage { none, send, receive, read, eof, close };
struct Handle {
DWORD_PTR context{};
WINHTTP_STATUS_CALLBACK callback{};
DWORD notifications{};
bool closed{};
};
struct State {
dingosdk::https::Watch* watch{};
Stage stop_at{Stage::none}, fail_at{Stage::none};
bool timeout{}, callback_error{}, content_length{true}, downgrade{};
unsigned opens{}, reads{}, closing_callbacks{}, pending_buffer_closes{};
DWORD redirect{}, request_flags{}, session_flags{};
void* pending_buffer{};
Handle session, connection, request;
std::string body{"payload"};
};
State state;
void stop() {
if (state.timeout) state.watch->deadline_us.store(1);
else state.watch->cancel.store(true);
}
void notify(DWORD status, void* info = nullptr, DWORD size = 0) {
state.request.callback(&state.request, state.request.context, status, info, size);
}
bool pending(Stage stage) {
if (state.stop_at != stage) return false;
stop();
return true;
}
bool failure(Stage stage) {
if (state.fail_at != stage) return false;
if (state.callback_error) {
WINHTTP_ASYNC_RESULT error{};
error.dwError = ERROR_WINHTTP_CONNECTION_ERROR;
notify(WINHTTP_CALLBACK_STATUS_REQUEST_ERROR, &error, sizeof(error));
} else {
SetLastError(ERROR_WINHTTP_CONNECTION_ERROR);
}
return true;
}
HINTERNET WINAPI open(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD flags) {
++state.opens;
state.session_flags = flags;
return &state.session;
}
HINTERNET WINAPI connect(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD) {
return &state.connection;
}
HINTERNET WINAPI open_request(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD flags) {
state.request_flags = flags;
return &state.request;
}
BOOL WINAPI set_timeouts(HINTERNET, int, int, int, int) { return TRUE; }
BOOL WINAPI set_option(HINTERNET handle, DWORD option, LPVOID value, DWORD) {
if (option == WINHTTP_OPTION_CONTEXT_VALUE)
static_cast<Handle*>(handle)->context = *static_cast<DWORD_PTR*>(value);
if (option == WINHTTP_OPTION_REDIRECT_POLICY) state.redirect = *static_cast<DWORD*>(value);
return TRUE;
}
WINHTTP_STATUS_CALLBACK WINAPI set_callback(HINTERNET handle, WINHTTP_STATUS_CALLBACK callback,
DWORD notifications, DWORD_PTR) {
auto& request = *static_cast<Handle*>(handle);
const auto previous = request.callback;
request.callback = callback;
request.notifications = notifications;
return previous;
}
BOOL WINAPI send(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR context) {
state.request.context = context;
if (pending(Stage::send)) return TRUE;
if (failure(Stage::send)) return state.callback_error ? TRUE : FALSE;
notify(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE);
return TRUE;
}
BOOL WINAPI receive(HINTERNET, LPVOID) {
if (pending(Stage::receive)) return TRUE;
if (failure(Stage::receive)) return state.callback_error ? TRUE : FALSE;
if (state.downgrade && state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP) {
SetLastError(ERROR_WINHTTP_REDIRECT_FAILED);
return FALSE;
}
notify(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE);
return TRUE;
}
BOOL WINAPI query(HINTERNET, DWORD query, LPCWSTR, LPVOID value, LPDWORD size, LPDWORD) {
if (query == (WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER)) {
*static_cast<DWORD*>(value) = 200;
} else if (query == (WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER) && state.content_length) {
*static_cast<DWORD*>(value) = static_cast<DWORD>(state.body.size());
} else {
SetLastError(ERROR_WINHTTP_HEADER_NOT_FOUND);
return FALSE;
}
*size = sizeof(DWORD);
return TRUE;
}
BOOL WINAPI read(HINTERNET, LPVOID buffer, DWORD capacity, LPDWORD count) {
// The production async path must receive the count through its callback.
if (count || capacity < state.body.size()) {
SetLastError(ERROR_INVALID_PARAMETER);
return FALSE;
}
if (++state.reads == 1) {
std::memcpy(buffer, state.body.data(), state.body.size());
notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, static_cast<DWORD>(state.body.size()));
} else {
if (state.stop_at == Stage::read) {
state.pending_buffer = buffer;
stop();
return TRUE;
}
if (failure(Stage::read)) return state.callback_error ? TRUE : FALSE;
if (state.stop_at == Stage::eof) stop();
notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, 0);
}
return TRUE;
}
BOOL WINAPI close(HINTERNET handle) {
auto& object = *static_cast<Handle*>(handle);
if (object.closed) {
SetLastError(ERROR_INVALID_HANDLE);
return FALSE;
}
object.closed = true;
if (handle == &state.request && object.callback) {
if (state.pending_buffer) {
// Pending I/O can still touch the caller's buffer during closure.
*static_cast<char*>(state.pending_buffer) = 'x';
++state.pending_buffer_closes;
state.pending_buffer = nullptr;
}
if (state.stop_at == Stage::close) stop();
if (object.context && (object.notifications & WINHTTP_CALLBACK_FLAG_HANDLES)) {
++state.closing_callbacks;
notify(WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING);
}
}
return TRUE;
}
}
#define WinHttpOpen fake_http::open
#define WinHttpConnect fake_http::connect
#define WinHttpOpenRequest fake_http::open_request
#define WinHttpSetTimeouts fake_http::set_timeouts
#define WinHttpSetOption fake_http::set_option
#define WinHttpSetStatusCallback fake_http::set_callback
#define WinHttpSendRequest fake_http::send
#define WinHttpReceiveResponse fake_http::receive
#define WinHttpQueryHeaders fake_http::query
#define WinHttpReadData fake_http::read
#define WinHttpCloseHandle fake_http::close
#include "../https_download.cpp"
#undef WinHttpOpen
#undef WinHttpConnect
#undef WinHttpOpenRequest
#undef WinHttpSetTimeouts
#undef WinHttpSetOption
#undef WinHttpSetStatusCallback
#undef WinHttpSendRequest
#undef WinHttpReceiveResponse
#undef WinHttpQueryHeaders
#undef WinHttpReadData
#undef WinHttpCloseHandle
namespace {
int failures{};
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
std::string contents(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
struct Fixtures {
std::filesystem::path root;
bool created;
explicit Fixtures(std::filesystem::path path) : root(std::move(path)),
created(std::filesystem::create_directory(root)) {}
~Fixtures() {
if (created) {
std::error_code error;
std::filesystem::remove_all(root, error);
}
}
};
void reset(dingosdk::https::Watch& watch) {
fake_http::state = {};
fake_http::state.watch = &watch;
watch.cancel.store(false);
watch.deadline_us.store(0);
watch.received.store(0);
watch.total.store(0);
}
}
int main(int argc, char** argv) {
using namespace dingosdk::https;
using fake_http::Stage;
Fixtures fixtures(argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::current_path() /
("https-download-timeout-fixtures-" + std::to_string(GetCurrentProcessId())));
if (!fixtures.created) {
std::cerr << "Fixture directory already exists; refusing to overwrite it\n";
return 1;
}
const auto path = fixtures.root / "download.bin";
Watch watch;
const auto get = [&](std::uint64_t limit = 1024, std::uint32_t seconds = 5) {
return dingosdk::https::get(L"https://example.invalid/file", path, limit, seconds, L"test", &watch);
};
const auto removed = [&] { return !std::filesystem::exists(path); };
const auto closed = [&] {
return fake_http::state.closing_callbacks == 1 && fake_http::state.request.closed &&
fake_http::state.connection.closed && fake_http::state.session.closed;
};
reset(watch);
auto result = get();
check(result.ok && result.http_status == 200 && result.error == 0, "actual GET succeeds");
check(contents(path) == "payload" && watch.received.load() == 7 && watch.total.load() == 7,
"successful bytes and progress are retained");
check(closed(), "successful request receives final closing callback");
check(fake_http::state.session_flags & WINHTTP_FLAG_ASYNC, "session uses asynchronous WinHTTP");
check(fake_http::state.request_flags & WINHTTP_FLAG_SECURE, "request stays HTTPS");
check(fake_http::state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP,
"HTTPS downgrade redirect policy is configured");
std::filesystem::remove(path);
for (const auto stage : {Stage::send, Stage::receive, Stage::read, Stage::eof, Stage::close}) {
for (const bool timeout : {false, true}) {
reset(watch);
fake_http::state.stop_at = stage;
fake_http::state.timeout = timeout;
result = get();
check(!result.ok && result.error == static_cast<DWORD>(timeout ? ERROR_TIMEOUT : ERROR_CANCELLED),
"pending operation or final completion reports the authoritative stop reason");
check(removed(), "stopped GET removes only its created output, including partial bytes");
check(closed(), "stopped request completes handle-closing lifetime handshake");
if (stage == Stage::read)
check(watch.received.load() == 7 && fake_http::state.pending_buffer_closes == 1,
"partial output is cleaned while pending read buffer survives close");
}
}
for (const auto stage : {Stage::send, Stage::receive, Stage::read}) {
for (const bool callback_error : {false, true}) {
reset(watch);
fake_http::state.fail_at = stage;
fake_http::state.callback_error = callback_error;
result = get();
check(!result.ok && result.error == ERROR_WINHTTP_CONNECTION_ERROR,
"immediate and callback API errors are reported");
check(removed() && closed(), "API errors clean up output and close registered request");
}
}
reset(watch);
watch.deadline_us.store(1);
result = get();
check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0 && removed(),
"already expired external deadline prevents network and output creation");
reset(watch);
watch.deadline_us.store(static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count()) + 60000000);
result = get(1024, 0);
check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0,
"internal timeout wins over a later external steady-clock deadline");
reset(watch);
watch.cancel.store(true);
watch.deadline_us.store(1);
result = get();
check(!result.ok && result.error == ERROR_CANCELLED && fake_http::state.opens == 0,
"explicit cancellation wins when both stop conditions hold");
reset(watch);
{
std::ofstream output(path, std::ios::binary);
output << "existing destination";
}
result = get();
check(!result.ok && contents(path) == "existing destination",
"CREATE_NEW failure preserves a pre-existing destination");
check(closed(), "existing-destination failure still closes the request");
std::filesystem::remove(path);
for (const bool content_length : {false, true}) {
reset(watch);
fake_http::state.content_length = content_length;
result = get(3);
check(!result.ok && result.error == ERROR_FILE_TOO_LARGE && removed() && closed(),
"announced and streamed oversized bodies remain rejected and cleaned up");
}
reset(watch);
fake_http::state.downgrade = true;
result = get();
check(!result.ok && result.error == ERROR_WINHTTP_REDIRECT_FAILED && removed() && closed(),
"HTTPS-to-HTTP redirects remain refused");
reset(watch);
fake_http::state.body.clear();
result = get();
check(!result.ok && result.error == ERROR_BAD_LENGTH && removed() && closed(),
"empty body remains rejected and its created output is removed");
reset(watch);
fake_http::state.stop_at = Stage::send;
const auto text = get_text(L"https://example.invalid/metadata", 1024, 5, L"test", &result, &watch);
check(!text && !result.ok && result.error == ERROR_CANCELLED && closed(),
"get_text forwards its Watch into the actual asynchronous GET");
if (failures) return 1;
std::cout << "HTTPS deadline/cancellation tests passed\n";
return 0;
}
@@ -14,15 +14,20 @@
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/toc.h"
#include "Engine/Vfs/game_archives.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_merge_internal.h"
#include "Engine/Vfs/native_db.h"
#include <Windows.h>
#include <algorithm>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <optional>
#include <set>
#include <string>
#include <vector>
@@ -291,6 +296,8 @@ std::string describe(const mods::MergeReport& report) {
constexpr char shared_toc[] = "test_shared.toc";
constexpr char map_toc[] = "levels/test/map.toc";
constexpr char map_superbundle[] = "Win32/levels/test/map";
constexpr char second_map_toc[] = "levels/test/map2.toc";
constexpr char second_map_superbundle[] = "Win32/levels/test/map2";
// The level mod alone: what its copy of the bundle holds with nothing carried into it.
int map_only() {
@@ -395,6 +402,27 @@ void two_music_mods_both_carried_into_maps_copy(int baseline) {
std::to_string(files) + " vs " + std::to_string(baseline + 6));
}
// Two mods edit the playlist and two others carry the game's copy of the bundle untouched. Only
// the copies equal to the game's are left out for combining: every test/other, but neither edit
// of the playlist (the carriers' playlists take the edits, so they are not the game's either).
void copies_equal_to_the_games_are_not_edits() {
Fixture fixture("untouched-copies");
const std::vector<Ebx> music1{{"test/playlist", 1, ebx_document(guid(1), {guid(10)})},
{"test/other", 0, ebx_document(guid(2), {})},
{"test/song1", 0, ebx_document(guid(10), {})}};
const std::vector<Ebx> music2{{"test/playlist", 2, ebx_document(guid(1), {guid(20)})},
{"test/other", 0, ebx_document(guid(2), {})},
{"test/song2", 0, ebx_document(guid(20), {})}};
fixture.add("carrier1", false, shared_toc, {}, game_copy());
fixture.add("music1", false, shared_toc, {}, music1);
fixture.add("music2", false, shared_toc, {}, music2);
fixture.add("carrier2", false, shared_toc, {}, game_copy());
const auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "untouched copies: the merge builds the patch\n" + describe(report));
expect(report.unchangedCopies == 4, "untouched copies: " + std::to_string(report.unchangedCopies) +
" copies equal to the game's were left out, not 4");
}
// The game keeps the playlist in a second bundle too, as it keeps the music playlist in eight. A
// mod that ships its own copy of that one (it changed something else in it) takes the music mod's
// playlist there. The song, its wave and the wave's resource were added beside the playlist in
@@ -453,6 +481,177 @@ void replaced_while_the_game_runs() {
report = mods::merge_mods(fixture.catalog, {}, {.live = true});
expect(report.built && report.issue.empty() && !noted("placed again"), "replaced: placed again without a change\n" + describe(report));
}
// The merged patch, read the way the merge reads it.
struct Patch {
explicit Patch(const Fixture& fixture)
: root(fixture.catalog.root / mods::generated_folder), base(fixture.catalog.data_root / "Data"),
game(fixture.catalog.data_root), layout(vfs::read_layout(root / "layout.toc")), store(base, root, layout.root) {}
// The bytes a placement names; empty, with a FAIL, when they are not on disk.
Bytes bytes(const fb::CasIdentifier& location, std::uint32_t offset, std::uint32_t size, const std::string& what) {
try {
const auto& from = location.patch ? root : base;
const auto length = store.archive_size(from, location);
expect(length && std::uint64_t{offset} + size <= *length,
what + " points at byte " + std::to_string(offset) + " of " + store.describe(location) +
(length ? ", past its end" : ", which is not there"));
if (!length || std::uint64_t{offset} + size > *length) return {};
return store.read(from, location, offset, size);
} catch (const std::exception& error) {
expect(false, what + ": " + error.what());
return {};
}
}
// Where the merged copy of the bundle in `toc` keeps `asset`.
std::optional<fb::BundleFileInfo> file(const std::string& toc, const std::string& asset) {
const auto path = root / "Win32" / fs::path(toc);
if (!fs::exists(path)) return std::nullopt;
for (const auto& bundle : fb::read_toc(read(path)).bundles) {
if (bundle.name != bundle_name) continue;
const auto listing = mods::detail::list_bundle(store, root, base, bundle, game);
if (!listing) return std::nullopt;
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index)
if (listing->manifest.ebx[index].name == asset) return listing->files.at(listing->first + index);
}
return std::nullopt;
}
fs::path root, base, game;
vfs::Layout layout;
mods::detail::CasStore store;
};
// How many times `part` is in `whole`.
std::size_t occurrences(const Bytes& whole, const Bytes& part) {
std::size_t count{};
for (auto at = whole.begin(); (at = std::search(at, whole.end(), part.begin(), part.end())) != whole.end(); ++at) ++count;
return count;
}
// Two maps each carry the music mod's song. Both copies read the one payload.
void carried_payload_written_once() {
Fixture fixture("carried-once");
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
fixture.add("map2", true, second_map_toc, {second_map_superbundle}, game_copy());
const auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "carried once: the merge builds the patch\n" + describe(report));
if (!report.built) return;
Patch patch(fixture);
const auto first = patch.file(map_toc, "test/song");
const auto second = patch.file(second_map_toc, "test/song");
expect(first && second, "carried once: both maps' copies hold test/song");
if (!first || !second) return;
expect(first->location == second->location && first->offset == second->offset,
"carried once: the two copies read different payloads (byte " + std::to_string(first->offset) + " and byte " +
std::to_string(second->offset) + ")");
const auto song = encoded(ebx_document(guid(10), {guid(11)}));
expect(patch.bytes(first->location, first->offset, first->size, "carried once: test/song") == song,
"carried once: the placement does not hold the song's bytes");
const auto size = patch.store.archive_size(patch.root, first->location);
const auto archive = size ? patch.bytes(first->location, 0, static_cast<std::uint32_t>(*size), "carried once: its archive") : Bytes{};
expect(occurrences(archive, song) == 1,
"carried once: the song is in its archive " + std::to_string(occurrences(archive, song)) + " times");
}
// Every placement in every merged TOC lies inside an archive that exists, and the merged layout
// declares only real archives.
void every_placement_lands_in_an_archive() {
Fixture fixture("placements");
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
// The song's audio as a TOC chunk of the music mod's own, named by the song (its import):
// it follows the song into the maps' TOCs.
const Bytes wave{std::byte{7}, std::byte{8}, std::byte{9}, std::byte{10}};
{
const auto toc = fixture.catalog.root / "music" / "Win32" / shared_toc;
const auto archive = fixture.catalog.root / "music" / "Win32" / "pkg" / "cas_01.cas";
auto document = fb::read_toc(read(toc));
auto bytes = read(archive);
const auto location = fb::read_bundle_region(document.bundles.front().region).files.front().location;
document.chunks.push_back({guid(11), location, static_cast<std::uint32_t>(bytes.size()), static_cast<std::uint32_t>(wave.size())});
bytes.insert(bytes.end(), wave.begin(), wave.end());
write(archive, bytes);
write(toc, fb::write_patch_toc(document.bundles, document.chunks, document.flags));
}
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
fixture.add("map2", true, second_map_toc, {second_map_superbundle}, game_copy());
const auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "placements: the merge builds the patch\n" + describe(report));
if (!report.built) return;
Patch patch(fixture);
std::size_t checked{};
std::set<std::string> withAudio;
for (const auto& entry : fs::recursive_directory_iterator(patch.root / "Win32")) {
if (entry.path().extension() != ".toc") continue;
const auto document = fb::read_toc(read(entry.path()));
for (const auto& bundle : document.bundles)
for (const auto& file : fb::read_bundle_region(bundle.region).files) {
(void)patch.bytes(file.location, file.offset, file.size, "placements: " + bundle.name);
++checked;
}
for (const auto& chunk : document.chunks) {
const auto held = patch.bytes(chunk.location, chunk.offset, chunk.size, "placements: chunk " + chunk.guid.string());
if (chunk.guid != guid(11)) continue;
const auto name = fs::relative(entry.path(), patch.root / "Win32").generic_string();
expect(held == wave, "placements: " + name + "'s chunk does not hold the song's audio");
if (held == wave) withAudio.insert(name);
}
}
expect(checked > 0, "placements: the merged TOCs hold placements");
expect(withAudio.contains(map_toc) && withAudio.contains(second_map_toc), "placements: both maps' TOCs carry the song's audio chunk");
const auto* table = patch.layout.root.field("layeredInstallChunkFiles");
expect(table != nullptr, "placements: the merged layout declares archives");
if (table)
vfs::for_each_install_chunk_file(table->payload(), [&](std::uint32_t, std::uint16_t archive) {
expect(!mods::detail::CasStore::placeholder(archive), "placements: the layout declares placeholder archive " + std::to_string(archive));
});
}
// A mod archive numbered like a placeholder takes a free number: its placements land on its bytes,
// and the layout never declares the placeholder.
void a_mod_archive_numbered_like_a_placeholder() {
Fixture fixture("placeholder-number");
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
const auto mod = fixture.catalog.root / "map";
const auto toc = mod / "Win32" / map_toc;
auto document = fb::read_toc(read(toc));
for (auto& bundle : document.bundles) {
auto region = fb::read_bundle_region(bundle.region);
for (auto& file : region.files) file.location.archive = mods::detail::CasStore::carried_archive;
bundle.region = fb::write_bundle_region(region.files, region.inlineManifest);
}
write(toc, fb::write_patch_toc(document.bundles, document.chunks, document.flags));
fs::rename(mod / "Win32" / "pkg" / "cas_01.cas", mod / "Win32" / "pkg" / vfs::archive_file(mods::detail::CasStore::carried_archive));
const auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "placeholder number: the merge builds the patch\n" + describe(report));
if (!report.built) return;
Patch patch(fixture);
const auto other = patch.file(map_toc, "test/other");
expect(other && !mods::detail::CasStore::placeholder(other->location) &&
patch.bytes(other->location, other->offset, other->size, "placeholder number: test/other") ==
encoded(ebx_document(guid(2), {})),
"placeholder number: the map's own asset does not read its bytes");
if (const auto* table = patch.layout.root.field("layeredInstallChunkFiles"))
vfs::for_each_install_chunk_file(table->payload(), [&](std::uint32_t, std::uint16_t archive) {
expect(!mods::detail::CasStore::placeholder(archive), "placeholder number: the layout declares archive " + std::to_string(archive));
});
}
// A merge built again from the same mods writes the same bytes.
void two_merges_write_the_same_bytes() {
Fixture fixture("same-bytes");
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
fixture.add("map2", true, second_map_toc, {second_map_superbundle}, game_copy());
const auto patch = fixture.catalog.root / mods::generated_folder;
auto report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built, "same bytes: the first merge\n" + describe(report));
const auto archive = read(patch / "Win32" / "pkg" / "cas_01.cas");
const auto toc = read(patch / "Win32" / fs::path(second_map_toc));
fs::remove(patch / mods::detail::stamp_file);
report = mods::merge_mods(fixture.catalog);
expect(report.issue.empty() && report.built && !report.reused, "same bytes: the second merge\n" + describe(report));
expect(read(patch / "Win32" / "pkg" / "cas_01.cas") == archive, "same bytes: the patch's own archive differs");
expect(read(patch / "Win32" / fs::path(second_map_toc)) == toc, "same bytes: the second map's TOC differs");
}
} // namespace
int main() try {
@@ -465,8 +664,13 @@ int main() try {
same_name_from_two_mods(true);
same_name_from_two_mods(false);
two_music_mods_both_carried_into_maps_copy(baseline);
copies_equal_to_the_games_are_not_edits();
carried_into_another_bundle_with_the_list(true);
carried_into_another_bundle_with_the_list(false);
carried_payload_written_once();
every_placement_lands_in_an_archive();
a_mod_archive_numbered_like_a_placeholder();
two_merges_write_the_same_bytes();
if (failures) {
std::cerr << failures << " failure(s)\n";
return 1;
+191
View File
@@ -0,0 +1,191 @@
// The meshes a mod ships, checked as the GPU would read them (frostbite::check_mesh_lod): a
// section whose vertices or indices run past their buffer, or a triangle naming a vertex outside
// its section, sends a draw, or a ray traced against the mesh, to memory that is not the mesh's.
// For finding what in a map hangs or faults the GPU when it loads.
//
// Arguments: <Skate folder> <mod folder> [<bundle filter>]. Every MeshSet the mod's own files
// hold is read, each level of detail checked; what is wrong is listed by bundle and mesh, then
// how many meshes each bundle has and how many are wrong.
#include "Engine/Resource/binary_bundle.h"
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/mesh_set.h"
#include "Engine/Resource/toc.h"
#include "Engine/Vfs/mod_merge_internal.h"
#include <Windows.h>
#include <algorithm>
#include <iostream>
#include <map>
#include <cstring>
#include <string>
namespace fs = std::filesystem;
namespace fb = dingosdk::frostbite;
using namespace dingosdk::mods::detail;
namespace {
constexpr std::uint32_t mesh_set_type = 0x49b156d4;
struct Tally {
std::size_t meshes{}, wrong{}, unreadable{};
};
}
int wmain(int argc, wchar_t** argv) {
if (argc < 3) {
std::cerr << "Usage: dingosdk_mod_mesh_check <Skate folder> <mod folder> [<bundle filter>]\n";
return 2;
}
const fs::path game_root = argv[1], mod = argv[2];
std::string filter;
if (argc > 3) for (const wchar_t* c = argv[3]; *c; ++c) filter.push_back(static_cast<char>(*c));
const auto base_root = game_root / L"Data";
try {
const auto layout = dingosdk::vfs::read_layout(base_root / L"layout.toc");
// (Nothing is written: the store is only read through.)
const auto scratch = fs::temp_directory_path() / L"reskate-mesh-check";
CasStore store(base_root, scratch, layout.root);
std::map<std::string, Tally> tallies;
std::map<std::uint32_t, std::size_t> formats;
for (const auto& relative : scan(mod).tocs) {
fb::TocDocument own;
try {
own = fb::read_toc(read_file(mod / fs::path(relative)));
} catch (const std::exception& error) {
std::cout << "unreadable toc " << fs::path(relative).string() << ": " << error.what() << "\n";
continue;
}
for (const auto& bundle : own.bundles) {
if (!filter.empty() && bundle.name.find(filter) == std::string::npos) continue;
std::optional<Listing> listing;
try {
listing = list_bundle(store, mod, base_root, bundle, game_root);
} catch (const std::exception& error) {
std::cout << "unreadable bundle " << bundle.name << ": " << error.what() << "\n";
continue;
}
if (!listing) continue;
const auto& manifest = listing->manifest;
auto& tally = tallies[bundle.name];
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
const auto file = manifest.ebx.size() + index;
const auto at = listing->first + file;
if (manifest.resources[index].resourceType != mesh_set_type || at >= listing->files.size() ||
!listing->files[at].location.patch)
continue; // (the game's own meshes are not the mod's to check)
const auto& name = manifest.resources[index].name;
++tally.meshes;
std::vector<std::byte> resource;
std::size_t lods{};
try {
resource = read_asset(store, mod, base_root, *listing, file, game_root);
(void)fb::any_mesh_lod_geometry(resource, 0, &lods);
} catch (const std::exception& error) {
++tally.unreadable;
// (Each layout this reader does not know, once: its first words and size.)
static std::map<std::string, bool> shown;
std::string head;
for (std::size_t word = 0; word < 16 && (word + 1) * 4 <= resource.size(); ++word) {
std::uint32_t value{};
std::memcpy(&value, resource.data() + word * 4, 4);
head += std::to_string(value) + " ";
}
if (!shown[head.substr(0, 48)]) {
shown[head.substr(0, 48)] = true;
std::cout << "layout " << head << "(" << resource.size() << " bytes) e.g. " << name << ": " << error.what() << "\n";
// (RESKATE_MESH_DUMP=1: the first one whole, eight bytes a line, for working out its layout.)
static bool dumped{};
if (!dumped && GetEnvironmentVariableW(L"RESKATE_MESH_DUMP", nullptr, 0)) {
dumped = true;
for (std::size_t offset = 0; offset + 8 <= resource.size(); offset += 8) {
std::uint32_t low{}, high{};
std::memcpy(&low, resource.data() + offset, 4);
std::memcpy(&high, resource.data() + offset + 4, 4);
std::cout << " +" << at << ": " << low << " " << high << "\n";
}
}
}
continue;
}
// (RESKATE_MESH_DUMP_NAME=<part of a name>: that mesh's resource, offset and 32-bit word a line.)
if (wchar_t wanted_name[256]{}; GetEnvironmentVariableW(L"RESKATE_MESH_DUMP_NAME", wanted_name, 256)) {
std::string part;
for (const wchar_t* c = wanted_name; *c; ++c) part.push_back(static_cast<char>(*c));
if (name.find(part) != std::string::npos)
for (std::size_t offset = 0; offset + 4 <= resource.size(); offset += 4) {
std::uint32_t value{};
std::memcpy(&value, resource.data() + offset, 4);
std::cout << "dump " << offset << " " << value << "\n";
}
}
bool wrong{};
for (std::size_t lod = 0; lod < lods; ++lod) {
try {
const auto wanted = fb::any_mesh_lod_geometry(resource, lod).chunk;
std::vector<std::byte> geometry;
for (std::size_t chunk = 0; chunk < manifest.chunks.size() && geometry.empty(); ++chunk)
if (manifest.chunks[chunk].guid == wanted)
geometry = read_asset(store, mod, base_root, *listing,
manifest.ebx.size() + manifest.resources.size() + chunk, game_root);
for (auto chunk = own.chunks.begin(); chunk != own.chunks.end() && geometry.empty(); ++chunk)
if (chunk->guid == wanted && !chunk->removed)
geometry = fb::decode_cas(store.read(chunk->location.patch ? mod : base_root, chunk->location,
chunk->offset, chunk->size), {game_root});
if (geometry.empty()) {
std::cout << bundle.name << " | " << name << " | lod " << lod << " | its geometry chunk is in neither the bundle nor its superbundle\n";
wrong = true;
continue;
}
std::uint32_t format{};
const auto problems = fb::check_mesh_lod(resource, lod, geometry, &format);
// (RESKATE_MESH_STATS=1: each level's size, for comparing a map's meshes with another's:
// sections, buffer bytes, and the most vertices and triangles a section has.)
if (GetEnvironmentVariableW(L"RESKATE_MESH_STATS", nullptr, 0)) {
const auto word = [&](std::size_t offset) {
std::uint64_t value{};
std::memcpy(&value, resource.data() + offset, 8);
return value;
};
const auto lod_at = static_cast<std::size_t>(word(48 + lod * 8)) + 16;
const auto sections = static_cast<std::uint32_t>(word(lod_at + 8));
const auto table = static_cast<std::size_t>(word(lod_at + 12)) + 16;
const auto sizes = fb::any_mesh_lod_geometry(resource, lod);
// (Static meshes' sections are 384 or 416 bytes; the larger section counts read by either.)
std::uint64_t most_vertices{}, most_triangles{};
for (std::uint32_t section = 0; section < sections; ++section)
for (const std::size_t stride : {384u, 416u}) {
const auto at_section = table + section * stride;
if (at_section + 48 > resource.size()) continue;
std::uint32_t triangles{}, vertices{};
std::memcpy(&triangles, resource.data() + at_section + 32, 4);
std::memcpy(&vertices, resource.data() + at_section + 44, 4);
if (vertices <= sizes.vertex_bytes && triangles * 6ull <= sizes.index_bytes + 6ull) {
most_vertices = std::max<std::uint64_t>(most_vertices, vertices);
most_triangles = std::max<std::uint64_t>(most_triangles, triangles);
}
}
std::cout << "stats " << bundle.name << " " << name << " " << lod << " " << sections << " " << sizes.vertex_bytes
<< " " << sizes.index_bytes << " " << most_vertices << " " << most_triangles << "\n";
}
++formats[format];
for (const auto& problem : problems) std::cout << bundle.name << " | " << name << " | lod " << lod << " | " << problem << "\n";
wrong = wrong || !problems.empty();
} catch (const std::exception& error) {
std::cout << bundle.name << " | " << name << " | lod " << lod << " | unreadable: " << error.what() << "\n";
}
}
tally.wrong += wrong;
}
}
}
std::cout << "\nBundle | meshes | wrong | unreadable\n";
for (const auto& [name, tally] : tallies)
if (tally.meshes) std::cout << name << " | " << tally.meshes << " | " << tally.wrong << " | " << tally.unreadable << "\n";
std::cout << "Index formats seen:";
for (const auto& [format, count] : formats) std::cout << " " << format << " (" << count << " levels)";
std::cout << "\n";
} catch (const std::exception& error) {
std::cerr << error.what() << "\n";
return 1;
}
return 0;
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// The built-in pack format (Engine/Vfs/builtin_packs.h), as BuiltinPacker writes it at build time
// from a zip in assets/builtin and ReSkate unpacks it: one zstd frame (with its content size and a
// checksum) holding
// "RSKPACK1", u32 file count,
// for each file: u16 name length, the name (UTF-8, '/' between folders), u64 size,
// then every file's bytes in the same order.
// Numbers little-endian.
#include <algorithm>
#include <array>
#include <cstdint>
#include <string_view>
namespace dingosdk::mods::builtin_pack {
inline constexpr std::array<char, 8> magic{'R', 'S', 'K', 'P', 'A', 'C', 'K', '1'};
// The most a pack may unpack to, and the largest zstd window it may use (zstd's own default
// limit for decoding, 128 MiB).
inline constexpr std::uint64_t max_bytes = 512ull * 1024 * 1024;
inline constexpr int max_window_log = 27;
// A path inside a pack: relative, forward slashes, no "..", plain characters (and spaces inside a
// name, as in a sound's "Body Impact 1.wav", but never at either end of one).
inline bool safe_name(std::string_view name) {
if (name.empty() || name.size() > 260 || name.front() == '/' || name.find(':') != std::string_view::npos ||
name.find('\\') != std::string_view::npos)
return false;
std::size_t start = 0;
while (start <= name.size()) {
const auto end = std::min(name.find('/', start), name.size());
const auto part = name.substr(start, end - start);
if (part.empty() || part == "." || part == ".." || part.front() == ' ' || part.back() == ' ') return false;
if (!std::all_of(part.begin(), part.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' ||
c == '-' || c == ' ';
}))
return false;
start = end + 1;
}
return true;
}
}
+310
View File
@@ -0,0 +1,310 @@
#include "builtin_packs.h"
#include "builtin_pack_format.h"
#include "Engine/Core/Platform/path_text.h"
#include <Windows.h>
#include <miniz.h>
#include <zstd.h>
#include <algorithm>
#include <cctype>
#include <iterator>
#include <format>
#include <fstream>
#include <cstring>
#include <span>
#include <string_view>
#include <utility>
#include <vector>
namespace dingosdk::mods {
namespace {
namespace fs = std::filesystem;
// Each pack: its RCDATA resource in ReSkate.dll (cmake/Runtime.cmake) and its folder name.
struct Embedded {
const wchar_t* resource;
const char* name;
};
constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}};
// Retired mods (builtin_packs.h), in small letters.
constexpr std::string_view retired[]{"fourth_studio-board_break"};
std::string lower_text(std::string_view text) {
std::string out(text);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
constexpr std::uint64_t max_pack_bytes = builtin_pack::max_bytes;
constexpr std::string_view version_file = ".builtin-version";
struct Source {
HMODULE module{};
bool owned{};
Source() = default;
Source(const Source&) = delete;
Source& operator=(const Source&) = delete;
~Source() {
if (owned && module) FreeLibrary(module);
}
};
bool carries_packs(HMODULE module) {
return module && std::any_of(std::begin(embedded), std::end(embedded), [&](const Embedded& pack) {
return FindResourceW(module, pack.resource, MAKEINTRESOURCEW(10) /* RT_RCDATA */) != nullptr;
});
}
// ReSkate.dll: this module when it is the DLL (the game), else ReSkate.dll beside the program
// (the launcher, which sits beside Skate.exe and the DLL), as data only.
void open_source(Source& source) {
HMODULE self{};
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&carries_packs), &self) && carries_packs(self)) {
source.module = self;
return;
}
if (const auto loaded = GetModuleHandleW(L"ReSkate.dll"); carries_packs(loaded)) {
source.module = loaded;
return;
}
std::wstring program(32768, L'\0');
const auto length = GetModuleFileNameW(nullptr, program.data(), static_cast<DWORD>(program.size()));
if (!length || length >= program.size()) return;
program.resize(length);
const auto dll = fs::path(program).parent_path() / L"ReSkate.dll";
source.module = LoadLibraryExW(dll.c_str(), nullptr, LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE);
source.owned = source.module != nullptr;
}
std::span<const unsigned char> resource_bytes(HMODULE module, const wchar_t* name) {
const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10));
if (!resource) return {};
const auto loaded = LoadResource(module, resource);
const auto size = SizeofResource(module, resource);
const auto* data = loaded ? static_cast<const unsigned char*>(LockResource(loaded)) : nullptr;
return data && size ? std::span<const unsigned char>(data, size) : std::span<const unsigned char>{};
}
// Which build of a pack this is: enough to tell when the one embedded changed.
std::string version_of(std::span<const unsigned char> bytes) {
std::uint64_t hash = 0xcbf29ce484222325ULL;
for (const auto byte : bytes) hash = (hash ^ byte) * 0x100000001b3ULL;
return std::format("{:016x}-{}", hash, bytes.size());
}
std::string read_text(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
bool safe_entry(std::string_view name) { return builtin_pack::safe_name(name); }
// Writes `size` bytes to a new file at `path` (its folders made first).
bool write_file(const fs::path& path, const unsigned char* data, std::uint64_t size) {
std::error_code error;
fs::create_directories(path.parent_path(), error);
struct File {
HANDLE value;
~File() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
} file{CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (file.value == INVALID_HANDLE_VALUE) return false;
while (size) {
const auto chunk = static_cast<DWORD>(std::min<std::uint64_t>(size, 64ull * 1024 * 1024));
DWORD done{};
if (!WriteFile(file.value, data, chunk, &done, nullptr) || done != chunk) return false;
data += chunk;
size -= chunk;
}
return true;
}
// A pack in ReSkate's own format (builtin_pack_format.h): one zstd frame, unpacked whole.
bool unpack_packed(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) {
const auto size = ZSTD_getFrameContentSize(bytes.data(), bytes.size());
if (size == ZSTD_CONTENTSIZE_ERROR || size == ZSTD_CONTENTSIZE_UNKNOWN || size > max_pack_bytes + 16ull * 1024 * 1024) {
issue = "the embedded pack is not one ReSkate made";
return false;
}
std::vector<unsigned char> plain(static_cast<std::size_t>(size));
const auto unpacked = ZSTD_decompress(plain.data(), plain.size(), bytes.data(), bytes.size());
if (ZSTD_isError(unpacked) || unpacked != plain.size()) {
issue = std::string("the embedded pack could not be decompressed: ") + (ZSTD_isError(unpacked) ? ZSTD_getErrorName(unpacked) : "short");
return false;
}
std::size_t at = 0;
const auto take = [&](void* out, std::size_t count) {
if (count > plain.size() - at) return false;
std::memcpy(out, plain.data() + at, count);
at += count;
return true;
};
std::array<char, builtin_pack::magic.size()> magic{};
std::uint32_t count{};
if (!take(magic.data(), magic.size()) || magic != builtin_pack::magic || !take(&count, sizeof(count)) || count > 100000) {
issue = "the embedded pack is not one ReSkate made";
return false;
}
std::vector<std::pair<std::string, std::uint64_t>> files(count);
std::uint64_t total{};
for (auto& [name, length] : files) {
std::uint16_t name_length{};
if (!take(&name_length, sizeof(name_length))) { issue = "the embedded pack's list is cut short"; return false; }
name.resize(name_length);
if (!take(name.data(), name.size()) || !take(&length, sizeof(length))) { issue = "the embedded pack's list is cut short"; return false; }
total += length;
if (!safe_entry(name) || total > max_pack_bytes) {
issue = "the embedded pack has an entry it may not: " + name;
return false;
}
}
for (const auto& [name, length] : files) {
if (length > plain.size() - at) { issue = "the embedded pack is cut short at " + name; return false; }
if (!write_file(target / fs::path(name), plain.data() + at, length)) { issue = "could not unpack " + name; return false; }
at += static_cast<std::size_t>(length);
}
if (at != plain.size()) {
issue = "the embedded pack has bytes past its last file";
return false;
}
return true;
}
bool unpack(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) {
// ReSkate's own format (a zstd frame); else a plain zip, as a build without the packer embeds.
if (bytes.size() >= 4 && bytes[0] == 0x28 && bytes[1] == 0xb5 && bytes[2] == 0x2f && bytes[3] == 0xfd)
return unpack_packed(bytes, target, issue);
struct Zip {
mz_zip_archive value{};
~Zip() { mz_zip_reader_end(&value); }
} zip;
if (!mz_zip_reader_init_mem(&zip.value, bytes.data(), bytes.size(), 0)) {
issue = "the embedded pack is not a zip";
return false;
}
const auto count = mz_zip_reader_get_num_files(&zip.value);
std::uint64_t total{};
for (mz_uint index = 0; index < count; ++index) {
mz_zip_archive_file_stat info{};
if (!mz_zip_reader_file_stat(&zip.value, index, &info) || info.m_is_encrypted) {
issue = "the embedded pack has an unreadable entry";
return false;
}
if (info.m_is_directory) continue;
total += info.m_uncomp_size;
if (!safe_entry(info.m_filename) || total > max_pack_bytes) {
issue = std::string("the embedded pack has an entry it may not: ") + info.m_filename;
return false;
}
const auto path = target / fs::path(info.m_filename);
fs::create_directories(path.parent_path());
struct File {
HANDLE value;
~File() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
} file{CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
struct Sink {
HANDLE file;
std::uint64_t written{}, limit;
} sink{file.value, 0, info.m_uncomp_size};
const auto write = [](void* opaque, mz_uint64 offset, const void* buffer, size_t size) -> size_t {
auto& target = *static_cast<Sink*>(opaque);
if (offset != target.written || size > target.limit - target.written) return 0;
DWORD done{};
if (!WriteFile(target.file, buffer, static_cast<DWORD>(size), &done, nullptr) || done != size) return 0;
target.written += size;
return size;
};
if (file.value == INVALID_HANDLE_VALUE || !mz_zip_reader_extract_to_callback(&zip.value, index, write, &sink, 0) ||
sink.written != info.m_uncomp_size) {
issue = std::string("could not unpack ") + info.m_filename;
return false;
}
}
return true;
}
} // namespace
bool unpack_builtin_pack(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) noexcept {
try {
return unpack(bytes, target, issue);
} catch (const std::exception& failure) {
issue = failure.what();
return false;
}
}
std::vector<BuiltinPack> official_packs(const fs::path& mods_root) noexcept {
std::vector<BuiltinPack> packs;
try {
std::error_code error;
for (fs::directory_iterator at(mods_root / std::string(official_folder), error), end; !error && at != end; at.increment(error)) {
if (!at->is_directory(error) || !fs::is_regular_file(at->path() / std::string(official_stamp), error)) continue;
const auto wide = at->path().filename().wstring();
if (wide.empty() || wide.front() == L'.' || wide.find(L".new") != std::wstring::npos) continue;
std::string name;
for (const auto c : wide) name.push_back(c < 0x80 ? static_cast<char>(c) : '_');
packs.push_back({std::move(name), at->path()});
}
std::sort(packs.begin(), packs.end(), [](const BuiltinPack& a, const BuiltinPack& b) { return a.name < b.name; });
} catch (...) {}
return packs;
}
bool retired_mod(std::string_view folder) noexcept {
try {
return std::find(std::begin(retired), std::end(retired), lower_text(folder)) != std::end(retired);
} catch (...) {
return false;
}
}
bool kept_by_reskate(const fs::path& mods_root, std::string_view folder) noexcept {
try {
if (folder.empty() || retired_mod(folder)) return !folder.empty();
std::error_code error;
return fs::is_regular_file(mods_root / std::string(official_folder) / std::string(folder) / std::string(official_stamp), error);
} catch (...) {
return false;
}
}
std::vector<BuiltinPack> unpack_builtin_packs(const fs::path& mods_root, std::vector<std::string>& notes) noexcept {
std::vector<BuiltinPack> packs;
try {
Source source;
open_source(source);
for (const auto& pack : embedded) {
if (!source.module) break;
const auto bytes = resource_bytes(source.module, pack.resource);
if (bytes.empty()) continue;
const auto directory = mods_root / std::string(builtin_folder) / pack.name;
const auto version = version_of(bytes);
std::error_code error;
if (read_text(directory / version_file) != version) {
// Unpacked beside it, then swapped in: a pack is never half there.
const auto staging = fs::path(directory.wstring() + std::format(L".unpacking-{}", GetCurrentProcessId()));
fs::remove_all(staging, error);
std::string issue;
if (!unpack(bytes, staging, issue)) {
fs::remove_all(staging, error);
notes.push_back(std::format("Built-in content {} could not be unpacked: {}", pack.name, issue));
continue;
}
std::ofstream(staging / version_file, std::ios::binary) << version;
fs::remove_all(directory, error);
fs::rename(staging, directory, error);
if (error) {
fs::remove_all(staging, error);
notes.push_back(std::format("Built-in content {} could not be put in place ({})", pack.name, error.message()));
continue;
}
notes.push_back(std::format("Built-in content {} unpacked ({} bytes)", pack.name, bytes.size()));
}
packs.push_back({std::string("reskate-builtin-") + pack.name, directory});
}
// What an older ReSkate carried and this one does not (it became an official mod, or was
// dropped) goes: the Board Break deck list reads every folder in here.
std::error_code error;
const auto folder = mods_root / std::string(builtin_folder);
std::vector<fs::path> stale;
for (fs::directory_iterator at(folder, error), end; !error && at != end; at.increment(error))
if (std::none_of(packs.begin(), packs.end(), [&](const BuiltinPack& pack) { return pack.directory == at->path(); }))
stale.push_back(at->path());
for (const auto& path : stale) {
fs::remove_all(path, error);
if (!error) notes.push_back("Built-in content " + path_utf8(path.filename()) + " is no longer carried; removed");
}
if (packs.empty()) fs::remove(folder, error);
} catch (const std::exception& failure) {
notes.push_back(std::string("Built-in content could not be prepared: ") + failure.what());
}
return packs;
}
} // namespace dingosdk::mods
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <filesystem>
#include <span>
#include <string>
#include <vector>
// Game content ReSkate carries itself: packs of game assets built by ReSkate Studio, zipped into
// ReSkate.dll (the FPV drone's model, tools/models/build_drone_pack.py). Nobody installs or sees
// them. Official mods (below) are the other content ReSkate keeps for every player. Before a merge each is unpacked under Mods/.reskate-builtin/<name> (a dot folder,
// which no mod list shows) when the one embedded differs from what is there, and the catalogue
// merges it after every installed mod, always on (mod_catalog.cpp).
namespace dingosdk::mods {
inline constexpr std::string_view builtin_folder = ".reskate-builtin";
struct BuiltinPack {
std::string name; // as the merge knows it: reskate-builtin-<pack>
std::filesystem::path directory; // Mods/.reskate-builtin/<pack>
};
// The packs ReSkate.dll carries, unpacked and current under `mods_root`. ReSkate.dll is the
// module running this when it is loaded (the game); otherwise the copy beside this program (the
// launcher) is opened as data. Never throws; `notes` says what was unpacked or went wrong.
std::vector<BuiltinPack> unpack_builtin_packs(const std::filesystem::path& mods_root,
std::vector<std::string>& notes) noexcept;
// One pack's bytes as ReSkate.dll carries them (builtin_pack_format.h, or a plain zip) unpacked
// into `target`; false with `issue` saying why. For tests and tools: unpack_builtin_packs uses it.
bool unpack_builtin_pack(std::span<const unsigned char> bytes, const std::filesystem::path& target, std::string& issue) noexcept;
// Official mods (Launcher/official_mods.h): Thunderstore packages the ReSkate backend names, which
// the launcher keeps installed at their latest version for every player in
// Mods/.reskate-official/<Namespace-Name>, each with a stamp file holding its version (written
// last, so a folder without one is not a whole mod). Always on: merged after every installed mod,
// like the packs above. Not in mods.json, and no mod list shows them.
inline constexpr std::string_view official_folder = ".reskate-official";
inline constexpr std::string_view official_stamp = ".official-version";
// The official mods in place under `mods_root`, by folder name. Never throws.
std::vector<BuiltinPack> official_packs(const std::filesystem::path& mods_root) noexcept;
// Mods that came before an official one and are retired with it (Fourth Studio's deprecated first
// Board Break), by folder name; the launcher takes them away and nothing loads them.
bool retired_mod(std::string_view folder) noexcept;
// Whether the installed mod `folder` (Mods/<folder>) is left out because ReSkate keeps it itself:
// an official mod in place under `mods_root`, or a retired one. Never throws.
bool kept_by_reskate(const std::filesystem::path& mods_root, std::string_view folder) noexcept;
} // namespace dingosdk::mods
+4 -3
View File
@@ -74,12 +74,13 @@ GameArchives::GameArchives(fs::path root, const native_db::Node& layout) : root_
const auto win32 = root_ / "Win32";
for (fs::recursive_directory_iterator it(win32, error), end; it != end && !error; it.increment(error)) {
if (!it->is_regular_file(error) || error) { error.clear(); continue; }
if (lower(it->path().extension().string()) != ".cas") continue;
const auto index = archive_index(it->path().stem().string());
if (lower(path_utf8(it->path().extension())) != ".cas") continue;
const auto index = archive_index(path_utf8(it->path().stem()));
if (!index) continue;
// Relative to Win32, because read() adds that itself.
const auto directory = fs::relative(it->path().parent_path(), win32, error).generic_string();
const auto relative = fs::relative(it->path().parent_path(), win32, error).generic_u8string();
if (error) { error.clear(); continue; }
const std::string directory(relative.begin(), relative.end());
directories_[directory].insert(*index);
}
for (const auto& [directory, archives] : directories_) byArchives[archives].push_back(directory);
+127 -17
View File
@@ -3,26 +3,107 @@
#include <winhttp.h>
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <fstream>
#include <iterator>
#include <mutex>
#include <string>
#include <utility>
namespace dingosdk::https {
namespace {
std::uint64_t now_us() noexcept {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}
struct Handle {
HANDLE value{INVALID_HANDLE_VALUE};
~Handle() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
const std::filesystem::path& path;
bool complete{};
~Handle() {
if (value != INVALID_HANDLE_VALUE) {
CloseHandle(value);
if (!complete) DeleteFileW(path.c_str());
}
}
};
struct Internet {
HINTERNET value{};
~Internet() { if (value) WinHttpCloseHandle(value); }
};
struct AsyncRequest {
HINTERNET value{};
std::mutex mutex;
std::condition_variable changed;
DWORD expected{}, error{}, read{};
bool completed{}, closed{};
~AsyncRequest() { close(); }
void close() {
if (!value) return;
// Async closure interrupts pending I/O. Keep the context and read buffer
// alive until WinHTTP's final callback, including on cancellation.
WinHttpCloseHandle(value);
std::unique_lock<std::mutex> lock(mutex);
changed.wait(lock, [&] { return closed; });
value = nullptr;
}
static void CALLBACK callback(HINTERNET, DWORD_PTR context, DWORD status, void* info, DWORD size) {
if (!context) return;
auto& request = *reinterpret_cast<AsyncRequest*>(context);
std::lock_guard<std::mutex> lock(request.mutex);
if (status == WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING) {
request.closed = true;
} else if (status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR) {
request.error = static_cast<WINHTTP_ASYNC_RESULT*>(info)->dwError;
request.completed = true;
} else if (status == request.expected) {
if (status == WINHTTP_CALLBACK_STATUS_READ_COMPLETE) request.read = size;
request.completed = true;
}
request.changed.notify_all();
}
void prepare(DWORD status) {
std::lock_guard<std::mutex> lock(mutex);
expected = status;
completed = false;
error = read = 0;
}
template<class Stop>
DWORD wait(const Stop& stop) {
std::unique_lock<std::mutex> lock(mutex);
for (;;) {
if (const auto stopped = stop()) return stopped;
if (completed) return error;
changed.wait_for(lock, std::chrono::milliseconds(20));
}
}
};
}
Download get(std::wstring_view url, const std::filesystem::path& destination,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch) {
Download result;
const auto fail = [&](DWORD error) { result.error = error; return result; };
const auto started = now_us();
const auto timeout_deadline = started + std::uint64_t{timeout_seconds} * 1000000;
const auto stop = [&]() -> DWORD {
if (watch && watch->cancel.load()) return ERROR_CANCELLED;
const auto external = watch ? watch->deadline_us.load() : 0;
const auto deadline = external && external < timeout_deadline ? external : timeout_deadline;
return now_us() >= deadline ? ERROR_TIMEOUT : ERROR_SUCCESS;
};
const auto fail = [&](DWORD error) {
const auto stopped = stop();
result.error = stopped ? stopped : error;
return result;
};
if (const auto error = stop()) return fail(error);
if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos)
return fail(ERROR_INVALID_PARAMETER);
const std::wstring address(url);
@@ -35,7 +116,7 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
const std::wstring host(parts.lpszHostName, parts.dwHostNameLength);
std::wstring resource = parts.dwUrlPathLength ? std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) : L"/";
if (parts.dwExtraInfoLength) resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength);
Internet session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0)};
Internet session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, WINHTTP_FLAG_ASYNC)};
if (!session.value) return fail(GetLastError());
if (!WinHttpSetTimeouts(session.value, 5000, 5000, 10000, 10000)) return fail(GetLastError());
Internet connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)};
@@ -43,30 +124,56 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
Internet request{WinHttpOpenRequest(connection.value, L"GET", resource.c_str(), nullptr, nullptr,
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)};
if (!request.value) return fail(GetLastError());
// Declared before AsyncRequest so pending reads cannot outlive their buffer.
std::array<char, 65536> buffer{};
AsyncRequest async;
DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP;
DWORD 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))) return fail(GetLastError());
const auto started = GetTickCount64();
if (!WinHttpSendRequest(request.value, nullptr, 0, nullptr, 0, 0, 0) || !WinHttpReceiveResponse(request.value, nullptr))
DWORD_PTR context = reinterpret_cast<DWORD_PTR>(&async);
if (!WinHttpSetOption(request.value, WINHTTP_OPTION_CONTEXT_VALUE, &context, sizeof(context)))
return fail(GetLastError());
if (WinHttpSetStatusCallback(request.value, AsyncRequest::callback,
WINHTTP_CALLBACK_FLAG_SENDREQUEST_COMPLETE | WINHTTP_CALLBACK_FLAG_HEADERS_AVAILABLE |
WINHTTP_CALLBACK_FLAG_READ_COMPLETE | WINHTTP_CALLBACK_FLAG_REQUEST_ERROR |
WINHTTP_CALLBACK_FLAG_HANDLES, 0) == WINHTTP_INVALID_STATUS_CALLBACK)
return fail(GetLastError());
async.value = std::exchange(request.value, nullptr);
const auto finish = [&](BOOL accepted) -> DWORD {
if (!accepted) {
const auto error = GetLastError();
if (error != ERROR_IO_PENDING) return error;
}
return async.wait(stop);
};
if (const auto error = stop()) return fail(error);
async.prepare(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE);
if (const auto error = finish(WinHttpSendRequest(async.value, nullptr, 0, nullptr, 0, 0, context)))
return fail(error);
async.prepare(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE);
if (const auto error = stop()) return fail(error);
if (const auto error = finish(WinHttpReceiveResponse(async.value, nullptr))) return fail(error);
DWORD size = sizeof(result.http_status);
if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
if (!WinHttpQueryHeaders(async.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &result.http_status, &size, nullptr)) return fail(GetLastError());
if (result.http_status != 200) return fail(ERROR_BAD_NET_RESP);
DWORD length{}; size = sizeof(length);
if (WinHttpQueryHeaders(request.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
if (WinHttpQueryHeaders(async.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &length, &size, nullptr) && length > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (watch) watch->total.store(length), watch->received.store(0);
Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (const auto error = stop()) return fail(error);
Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr),
destination};
if (output.value == INVALID_HANDLE_VALUE) return fail(GetLastError());
std::array<char, 65536> buffer{};
std::uint64_t total{};
for (;;) {
if (GetTickCount64() - started >= std::uint64_t{timeout_seconds} * 1000) return fail(ERROR_TIMEOUT);
if (watch && watch->cancel.load()) return fail(ERROR_CANCELLED);
DWORD read{}, written{};
if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) return fail(GetLastError());
if (const auto error = stop()) return fail(error);
async.prepare(WINHTTP_CALLBACK_STATUS_READ_COMPLETE);
if (const auto error = finish(WinHttpReadData(async.value, buffer.data(), static_cast<DWORD>(buffer.size()), nullptr)))
return fail(error);
const auto read = async.read;
DWORD written{};
if (!read) break;
if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT);
@@ -75,27 +182,30 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
}
if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError());
async.close();
if (const auto error = stop()) return fail(error);
output.complete = true;
result.ok = true;
return result;
}
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result) {
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result, Watch* watch) {
static std::atomic<unsigned> serial{};
wchar_t folder[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, folder);
const auto path = std::filesystem::path(length ? std::wstring(folder, length) : L".") /
(L"reskate-get-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(++serial) + L".tmp");
std::error_code error;
std::filesystem::remove(path, error); // get needs a fresh destination
const auto download = get(url, path, max_bytes, timeout_seconds, agent);
std::filesystem::remove(path, error); // discard a stale file left by an earlier process
const auto download = get(url, path, max_bytes, timeout_seconds, agent, watch);
if (result) *result = download;
std::optional<std::string> text;
if (download.ok) {
std::ifstream in(path, std::ios::binary);
text.emplace(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
std::filesystem::remove(path, error);
if (download.ok) std::filesystem::remove(path, error);
return text;
}
}
+3 -1
View File
@@ -16,6 +16,8 @@ struct Download {
struct Watch {
std::atomic<std::uint64_t> received{}, total{};
std::atomic<bool> cancel{};
// Absolute steady_clock epoch microseconds; zero leaves only the GET timeout.
std::atomic<std::uint64_t> deadline_us{};
};
// Streams an HTTPS GET into `destination`, which must not exist yet. Follows
// redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers
@@ -25,5 +27,5 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
// The body of the same GET, for an answer small enough to hold: nothing when it
// fails, and `result`, when given, says how.
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr);
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr, Watch* watch = nullptr);
}
+52 -7
View File
@@ -1,4 +1,6 @@
#include "mod_catalog.h"
#include "Engine/Core/Platform/path_text.h"
#include "builtin_packs.h"
#include "mod_merge.h"
#include "native_db.h"
@@ -127,23 +129,36 @@ std::vector<std::pair<std::string, fs::path>> decks_inside(const fs::path& mod)
std::error_code error;
for (fs::directory_iterator at(mod / L"decks", error), end; !error && at != end; at.increment(error)) {
if (!at->is_directory(error) || !fs::is_regular_file(at->path() / L"layout.toc", error)) continue;
auto name = lower(at->path().filename().string());
auto name = lower(path_utf8(at->path().filename()));
if (deck_name(name)) found.emplace_back(std::move(name), at->path());
}
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); });
return found;
}
// Every mod folder a deck can come from: those in the Mods folder (but one a built-in pack
// replaces), then the built-in packs (builtin_packs.h), which the merge also puts after them.
std::vector<fs::path> deck_mod_folders() {
std::vector<fs::path> found;
const auto mods = engine_data_root() / L"Mods";
std::error_code error;
for (fs::directory_iterator at(mods, error), end; !error && at != end; at.increment(error))
if (at->is_directory(error) && !kept_by_reskate(mods, path_utf8(at->path().filename()))) found.push_back(at->path());
error.clear();
for (fs::directory_iterator at(mods / std::string(builtin_folder), error), end; !error && at != end; at.increment(error))
if (at->is_directory(error) && at->path().filename().wstring().find(L".unpacking-") == std::wstring::npos)
found.push_back(at->path());
for (auto& official : official_packs(mods)) found.push_back(std::move(official.directory));
return found;
}
// Every deck in the Mods folder with the folder of the mod that brings it, in the menus' order,
// whatever mods.json says of that mod: the decks inside a pack, or else the one a folder is named for.
std::vector<std::pair<std::string, fs::path>> installed_decks() {
std::vector<std::pair<std::string, fs::path>> decks;
std::error_code error;
for (fs::directory_iterator at(engine_data_root() / L"Mods", error), end; !error && at != end; at.increment(error)) {
if (!at->is_directory(error)) continue;
auto inside = decks_inside(at->path());
for (auto& one : inside) decks.emplace_back(std::move(one.first), at->path());
for (const auto& folder : deck_mod_folders()) {
auto inside = decks_inside(folder);
for (auto& one : inside) decks.emplace_back(std::move(one.first), folder);
if (!inside.empty()) continue;
if (auto deck = board_break_deck_of(at->path().filename().string()); !deck.empty()) decks.emplace_back(std::move(deck), at->path());
if (auto deck = board_break_deck_of(path_utf8(folder.filename())); !deck.empty()) decks.emplace_back(std::move(deck), folder);
}
std::stable_sort(decks.begin(), decks.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); });
return decks;
@@ -220,6 +235,18 @@ std::vector<std::filesystem::path> board_break_folders() noexcept {
} catch (...) {}
return found;
}
std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept {
try {
const auto wanted = lower(deck);
for (const auto& folder : deck_mod_folders()) {
const auto inside = decks_inside(folder);
for (const auto& one : inside)
if (one.first == wanted) return one.second;
if (inside.empty() && board_break_deck_of(path_utf8(folder.filename())) == wanted) return folder;
}
} catch (...) {}
return {};
}
std::vector<std::string> installed_board_break_decks() noexcept {
std::vector<std::string> found;
try {
@@ -291,7 +318,12 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
result.issue = "The engine data root could not be resolved";
return result;
}
// Content ReSkate carries itself (builtin_packs.h), unpacked first: it makes the Mods folder
// when there is none, so the content loads without any mod installed.
std::vector<std::string> builtin_notes;
const auto builtin = unpack_builtin_packs(data_root / mods_folder, builtin_notes);
auto list = scan_mods(data_root);
for (auto& note : builtin_notes) list.notes.push_back(std::move(note));
result.root = std::move(list.root);
result.present = list.present;
if (!result.present) return result;
@@ -305,6 +337,19 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
try {
for (const auto& name : list.missing)
result.notes.push_back("mods.json names a folder that is not present: " + name);
// A mod ReSkate keeps itself (an official mod, or one retired with it: builtin_packs.h) is
// left out, should the launcher not have taken it away yet: its content would load twice.
std::erase_if(list.entries, [&](const ModEntry& entry) {
if (!kept_by_reskate(data_root / mods_folder, entry.mod.name)) return false;
result.notes.push_back(entry.mod.name + " is an official mod ReSkate keeps itself; the copy in the Mods folder is left out");
return true;
});
// After every installed mod, so none of theirs is ever shadowed by it; always on. Official
// mods (builtin_packs.h) the same way.
for (const auto& pack : builtin)
list.entries.push_back({describe_mod(pack.directory, pack.name, result.notes), true});
for (const auto& pack : official_packs(data_root / mods_folder))
list.entries.push_back({describe_mod(pack.directory, pack.name, result.notes), true});
// Board Break: a deck pack holds several decks in one mod folder (mod_catalog.h). Each deck
// inside becomes a mod of its own for the run, listed after the pack as <folder>#<deck>, so
// that a pack's decks are chosen, set aside and switched like decks in folders of their own.
+5 -2
View File
@@ -63,8 +63,8 @@ struct Catalog {
// A deck goes by its name in small letters. These are the decks the feature was made with, in the
// order the menus list them, and the console also knows them by their place here (deck 1 is the
// first). Any other deck comes after them, by its name: a new shape needs no change to ReSkate.
inline constexpr std::array<std::string_view, 8> board_break_decks{"standard", "wide", "concave", "powell",
"slim", "egg", "square", "shaped"};
inline constexpr std::array<std::string_view, 10> board_break_decks{"standard", "wide", "concave", "powell", "slim",
"egg", "square", "shaped", "oldschool", "pig"};
// The name of the deck a mod holds going by the mod's name (a folder's, or a pack's deck's), or
// nothing; and the folder name of the pack that one of a pack's decks is listed under, or nothing.
std::string board_break_deck_of(std::string_view mod);
@@ -78,6 +78,9 @@ bool save_board_break_deck(std::string_view deck) noexcept;
// beside its decks.
std::vector<std::string> installed_board_break_decks() noexcept;
std::vector<std::filesystem::path> board_break_folders() noexcept;
// The folder with a deck's own files (a pack's decks/<name>, or the Board_Break_<Name> folder): what
// it says of itself (deck.ini) is there. Empty when that deck is not installed.
std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept;
// What the mods in effect give the game: the launch's catalogue, or the one the last live apply
// read (live_mods tells with board_break_applied).
std::string board_break_in_play() noexcept;
+75
View File
@@ -0,0 +1,75 @@
#include "mod_item_sources.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Platform/path_text.h"
#include <algorithm>
#include <fstream>
#include <iterator>
#include <stdexcept>
namespace dingosdk::mods {
namespace fs = std::filesystem;
std::vector<ItemSource> read_item_sources(const fs::path& file) noexcept {
std::vector<ItemSource> sources;
try {
std::ifstream input(file, std::ios::binary);
if (!input) return {};
const std::string text{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
const auto root = Json::parse(text, {64u << 20, 32, 1u << 24});
const auto& mods = root.at("mods");
for (std::size_t i = 0; i < mods.size(); ++i) {
const auto& entry = mods.at(i);
ItemSource source{entry.at("mod").string(), entry.value("title", std::string()), entry.value("author", std::string()),
entry.value("version", std::string()), entry.value("folder", std::string()), {}};
const auto& keys = entry.at("items");
for (std::size_t k = 0; k < keys.size(); ++k) source.keys.push_back(keys.at(k).string());
sources.push_back(std::move(source));
}
} catch (...) {
return {}; // an unreadable file only means no card says where its item is from
}
return sources;
}
void write_item_sources(const fs::path& file, const std::vector<ItemSource>& sources) {
auto mods = Json::array();
for (const auto& source : sources) {
auto entry = Json::object();
entry["mod"] = source.mod;
entry["title"] = source.title;
entry["author"] = source.author;
entry["version"] = source.version;
entry["folder"] = source.folder;
entry["items"] = Json(source.keys);
mods.push_back(std::move(entry));
}
auto root = Json::object();
root["schema"] = 1u;
root["mods"] = std::move(mods);
const auto text = root.dump(1);
// Whole or not at all: the game may read it while a merge runs beside it.
auto temporary = file;
temporary += L".new";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output.write(text.data(), static_cast<std::streamsize>(text.size()));
if (!output.flush()) throw std::runtime_error("Cannot write " + path_utf8(temporary));
}
std::error_code error;
fs::rename(temporary, file, error);
if (error) {
fs::remove(temporary, error);
throw std::runtime_error("Cannot write " + path_utf8(file));
}
}
std::string item_source_name(const ItemSource& source) {
std::string name = source.title.empty() ? source.mod : source.title;
std::replace(name.begin(), name.end(), '_', ' ');
if (!source.author.empty()) name += " by " + source.author;
return name;
}
} // namespace dingosdk::mods
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
// Which mod each cosmetic item came from, so its card in the customization menu can say so:
// Mods/.reskate/reskate-items.json. The merge writes it from the items the store-copy check reads
// in every mod (mod_store_copies.h); the game reads it when it builds its item catalogue.
namespace dingosdk::mods {
inline constexpr char item_sources_file[] = "reskate-items.json";
struct ItemSource {
std::string mod; // its folder name
std::string title, author, version; // as its manifest gives them
std::string folder; // where it is, from Mods/ ("x" or ".reskate-official/x"), with its icon.png
std::vector<std::string> keys; // the items it adds or changes, lower case
};
// Highest priority first, as the merge orders the mods: where two list one item, the game uses
// the first one's, so that is where it is from. Empty when there is no file or it cannot be read.
[[nodiscard]] std::vector<ItemSource> read_item_sources(const std::filesystem::path& file) noexcept;
// Throws when the file cannot be written.
void write_item_sources(const std::filesystem::path& file, const std::vector<ItemSource>& sources);
// How a card names the mod: its title (Thunderstore names have underscores for spaces), and who
// made it when its manifest says: "ReSkate FPV Drone by zeex64".
[[nodiscard]] std::string item_source_name(const ItemSource& source);
} // namespace dingosdk::mods
+23 -18
View File
@@ -323,6 +323,28 @@ bool valid_mod_name(std::string_view name) {
});
}
Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector<std::string>& notes) {
std::error_code error;
Mod mod;
mod.name = std::move(name);
mod.title = mod.name;
mod.directory = directory;
mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error);
mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error);
for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) {
auto file = ascii_path(it->path().filename());
if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue;
file.resize(file.size() - 10);
mod.park_maps.push_back(std::move(file));
}
std::sort(mod.park_maps.begin(), mod.park_maps.end());
read_build_info(mod, notes);
read_mod_info(mod, notes);
read_levels(mod);
read_studio_marker(mod);
return mod;
}
ModList scan_mods(const std::filesystem::path& data_root) noexcept {
ModList result;
try {
@@ -364,24 +386,7 @@ ModList scan_mods(const std::filesystem::path& data_root) noexcept {
}
ModEntry entry;
entry.enabled = enabled;
auto& mod = entry.mod;
mod.name = found->second;
mod.title = mod.name;
mod.directory = result.root / mod.name;
mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error);
mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error);
for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) {
auto file = ascii_path(it->path().filename());
if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue;
file.resize(file.size() - 10);
mod.park_maps.push_back(std::move(file));
}
error.clear();
std::sort(mod.park_maps.begin(), mod.park_maps.end());
read_build_info(mod, result.notes);
read_mod_info(mod, result.notes);
read_levels(mod);
read_studio_marker(mod);
entry.mod = describe_mod(result.root / found->second, found->second, result.notes);
result.entries.push_back(std::move(entry));
}
// Only mods named in the exclusions file are fingerprinted.
+3
View File
@@ -110,6 +110,9 @@ std::string ascii_path(const std::filesystem::path& path);
// malformed, `issue` says why and `entries` still lists the folders (all
// shown as enabled) so a manager can offer to rewrite it.
ModList scan_mods(const std::filesystem::path& data_root) noexcept;
// One mod folder as scan_mods reads it: its layout, levels, parks, info files and build stamp.
// `name` is how it is known (for a folder under Mods/, the folder's name). Throws on I/O errors.
Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector<std::string>& notes);
// Writes mods.json for `entries` in order, replacing the file atomically.
// Throws with a readable message on failure.
+43 -8
View File
@@ -2,9 +2,11 @@
#include "content_cache.h"
#include "content_catalogs.h"
#include "mod_item_sources.h"
#include "mod_merge_internal.h"
#include "mod_store_copies.h"
#include "native_db.h"
#include "Engine/Core/Platform/path_text.h"
#include <Windows.h>
@@ -46,9 +48,9 @@ constexpr std::array<std::string_view, 2> launch_superbundles{"Win32/globals.toc
// (mod_store_copies.h) gets a problem; true when any does. The problem says no
// more than that the mod could not be merged: what was found is not for the
// mod's author to read. The catalogue is only read once a mod turns out to add
// an item at all.
// an item at all. `added` gets the items each mod read adds, when the check ran.
bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vector<std::string>& checked,
std::size_t threads, bool background) {
std::size_t threads, bool background, std::optional<std::vector<StoreCopies::Added>>& added) {
// (Only when some are skipped is the catalogue copied, without them.)
std::optional<Catalog> fewer;
if (!checked.empty()) {
@@ -63,6 +65,9 @@ bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vec
if (!store) store = content_cache::read_catalogs(content_cache::directory());
return store->reserved(key);
}, &report.notes, threads, background);
// (No mod to read is a check that ran and found no items.)
if (found.read || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; }))
added = std::move(found.added);
for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem);
return !found.mods.empty();
}
@@ -100,7 +105,9 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
fingerprint += " inactive " + mod.name + " " + mod_fingerprint(mod.directory);
if (options.live) {
fs::remove(output / stamp_file, error);
} else if (auto previous = previous_merge(output, fingerprint)) {
} else if (auto previous = previous_merge(output, fingerprint);
previous && (!storeKnown || fs::exists(output / item_sources_file, error))) {
// (A patch from before items were traced to their mods is built again, once.)
return std::move(*previous);
}
// A mod that adds copies of store items is not loaded at all. Found before
@@ -111,8 +118,28 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// launch (the player is waiting on nothing else), half of them and fewer while the game runs.
const auto cores = std::max(1U, std::thread::hardware_concurrency());
const std::size_t readers = std::min<std::size_t>(options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U);
std::optional<std::vector<StoreCopies::Added>> itemsRead;
if (storeKnown && store_copy_problems(catalog, report, options.live ? options.checked : std::vector<std::string>{},
readers - 1, options.live)) return report;
readers - 1, options.live, itemsRead)) return report;
// Which mod each item is from (mod_item_sources.h): what was just read, and for a mod a
// live merge did not read again, what the launch's merge found. Read before the patch
// folder is cleared.
std::optional<std::vector<ItemSource>> itemSources;
if (itemsRead) {
const auto before = options.live ? read_item_sources(output / item_sources_file) : std::vector<ItemSource>{};
itemSources.emplace();
for (const auto* mod : mods) {
std::error_code outside;
const auto folder = fs::relative(mod->directory, catalog.root, outside);
ItemSource source{mod->name, mod->title, mod->author, mod->version,
outside ? std::string() : path_utf8(folder.generic_wstring()), {}};
const auto read = std::ranges::find(*itemsRead, mod->name, &StoreCopies::Added::mod);
const auto earlier = std::ranges::find(before, mod->name, &ItemSource::mod);
if (read != itemsRead->end()) source.keys = std::move(read->keys);
else if (earlier != before.end()) source.keys = earlier->keys;
if (!source.keys.empty()) itemSources->push_back(std::move(source));
}
}
if (!options.live) fs::remove_all(output, error);
lap("checking the mods");
@@ -289,8 +316,8 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// Archive 1 stays the patch's own file, never a link to a mod's:
// a live apply appends manifests and new mods to it while the
// game has it open, and a linked file cannot be swapped for a
// private copy then (Windows keeps it in use).
claimed.insert(manifestArchive);
// private copy then (Windows keeps it in use). Placeholder indexes name no file.
claimed.insert({manifestArchive, CasStore::waiting_archive, CasStore::carried_archive});
if (number == manifestArchive) {
if (const auto free = claim(directory); free) {
spot.archive = *free;
@@ -442,7 +469,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
for (std::size_t index = 0; index < job.merged.bundles.size(); ++index) {
auto region = fb::read_bundle_region(job.merged.bundles[index].region);
if (std::ranges::any_of(region.files, [](const fb::BundleFileInfo& file) {
return CasStore::waits(file.location); }))
return CasStore::placeholder(file.location); }))
job.held.emplace_back(index, std::move(region));
}
} catch (...) {
@@ -509,9 +536,10 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
}
for (auto& chunk : job.merged.chunks) job.store->settled(chunk.location, chunk.offset);
for (const auto& [installChunk, archive] : job.used)
used.emplace(installChunk, archive == CasStore::waiting_archive ? manifestArchive : archive);
used.emplace(installChunk, CasStore::placeholder(archive) ? manifestArchive : archive);
report.mergedBundles += job.report.mergedBundles;
report.mergedAssets += job.report.mergedAssets;
report.unchangedCopies += job.report.unchangedCopies;
report.notes.insert(report.notes.end(), std::make_move_iterator(job.report.notes.begin()),
std::make_move_iterator(job.report.notes.end()));
for (auto& [mod, problems] : job.report.problems) {
@@ -690,6 +718,13 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
declare("layeredInstallChunkFiles");
declare("unlayeredInstallChunkFiles");
if (itemSources) {
try {
write_item_sources(output / item_sources_file, *itemSources);
} catch (const std::exception& failure) {
report.notes.push_back(std::string("Item cards will not say which mod they are from: ") + failure.what());
}
}
if (!options.live) {
const auto rebuilt = native_db::write(layout);
std::vector<unsigned char> file(native_db::envelope_size, 0);
+1
View File
@@ -17,6 +17,7 @@ struct MergeReport {
std::size_t superbundles{}; // superbundle TOCs written
std::size_t mergedBundles{}; // bundles combined from two or more mods
std::size_t mergedAssets{}; // EBX assets two or more mods had both edited
std::size_t unchangedCopies{}; // mods' asset copies equal to the game's, not kept for combining
std::size_t archives{}; // cas files linked in
std::string issue; // non-empty when nothing was produced
std::vector<std::string> notes;
+31 -2
View File
@@ -61,7 +61,7 @@ fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t arc
const auto path = archive_path(installChunk, archive);
if (waiting_) {
// A placement that waits names no archive, so there can be only the one to settle into.
if (archive == waiting_archive || (archive_ && *archive_ != archive))
if (placeholder(archive) || (archive_ && *archive_ != archive))
throw std::logic_error("A waiting store writes to one archive index");
archive_ = archive;
auto& held = held_[path.wstring()].bytes;
@@ -83,19 +83,48 @@ CasStore CasStore::waiting() const {
copy.archive_.reset();
copy.held_.clear();
copy.offsets_.clear();
copy.carried_.clear();
copy.carriedAt_.clear();
return copy;
}
fb::BundleFileInfo CasStore::carry(std::uint32_t installChunk, std::uint16_t archive, std::span<const std::byte> encoded) {
if (!waiting_) throw std::logic_error("Only a waiting store carries payloads");
if (placeholder(archive) || (archive_ && *archive_ != archive))
throw std::logic_error("A waiting store writes to one archive index");
// An install chunk no package names fails here, for this payload alone.
(void)directory(installChunk);
archive_ = archive;
carried_.push_back({installChunk, sha1_of(encoded), encoded, {}});
return {{true, installChunk, carried_archive}, static_cast<std::uint32_t>(carried_.size() - 1),
static_cast<std::uint32_t>(encoded.size())};
}
void CasStore::settle(CasStore& waiting) {
// In path order: which file comes first makes no difference to where anything lands.
for (auto& [path, held] : waiting.held_) {
held.at = append(fs::path(path), held.bytes);
held.bytes = {};
}
// Then the carried assets, each written once per install chunk.
for (auto& carried : waiting.carried_) {
const std::pair key{carried.installChunk, carried.sha1};
auto found = carriedAt_.find(key);
if (found == carriedAt_.end())
found = carriedAt_.emplace(key, write(carried.installChunk, *waiting.archive_, carried.bytes)).first;
carried.at = found->second;
}
}
bool CasStore::settled(fb::CasIdentifier& location, std::uint32_t& offset) const {
if (!waits(location)) return false;
if (!placeholder(location)) return false;
if (location.archive == carried_archive) {
if (offset >= carried_.size()) throw std::logic_error("A carried placement names no asset its store holds");
const auto& at = carried_[offset].at;
location = at.location;
offset = at.offset;
return true;
}
const auto found = archive_ ? held_.find(archive_path(location.installChunk, *archive_).wstring()) : held_.end();
if (found == held_.end()) throw std::logic_error("A placement waits in a store that holds nothing for it");
const auto moved = found->second.at + offset;
+80 -27
View File
@@ -1,6 +1,7 @@
#include "mod_merge_internal.h"
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/ebx_fixup_repair.h"
#include "Engine/Resource/bundle_ref_table.h"
#include "Engine/Resource/ebx_merge.h"
#include "Engine/Resource/ebx_writer.h"
@@ -46,7 +47,7 @@ struct Asset {
// Where one contributor's copy of an asset lives, so conflicting copies can be
// fetched and combined later.
struct Contribution {
fs::path root;
const fs::path* root{}; // the source's folder, in `sources` or baseRoot: neither moves while combine runs
fb::BundleFileInfo file; // still pointing at the archive it came from
fb::Sha1 sha1;
std::vector<std::byte> resourceMeta;
@@ -112,7 +113,7 @@ struct BundleState {
// are asset paths that share long beginnings, which an ordered map compares
// byte by byte at every step, and nothing reads these in key order.
std::unordered_map<std::string, std::size_t> seen;
// Every copy of an asset more than one source supplies, base included.
// Each source's copy of an asset, base first; a mod's copy equal to the base's first is left out.
std::unordered_map<std::string, std::vector<Contribution>> history;
// The base's own sha1 for each asset, so a mod that merely carries an
// unchanged copy can be told apart from one that changed it.
@@ -120,6 +121,12 @@ struct BundleState {
// Assets a mod added that a higher-priority mod's different asset of the
// same name then replaced: the key, and the sha1 of the copy that went.
std::vector<std::pair<std::string, fb::Sha1>> displaced;
// Whether `sha1` is the game's first copy of `key`: the copy edits are combined onto.
[[nodiscard]] bool matches_game_copy(const std::string& key, const fb::Sha1& sha1) const {
const auto copies = history.find(key);
return copies != history.end() && copies->second.front().base && copies->second.front().sha1 == sha1;
}
};
// The guids of the EBX a merged bundle ends up holding, to tell whether what a
@@ -370,8 +377,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
else if (addition.asset.kind == fb::AssetKind::resource)
companions.emplace(std::tuple{bundleName, addition.mod, lower(addition.asset.name)}, &addition);
}
// Where an addition's payload already went in this superbundle's patch
// archive, by install chunk: every copy that is given it reads the one.
// The carried place each addition got in this superbundle, by install chunk.
std::map<std::pair<std::uint32_t, const AssetAddition*>, fb::BundleFileInfo> carriedAt;
// The names any mod changed, to rule the rest out without a copy of each (name_hash).
std::unordered_set<std::uint64_t> changedNames, scriptNames;
@@ -439,7 +445,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// Studio writes the manifest as the region's first file, but a mod built
// by another tool or an older build may keep it elsewhere, so every small
// file is a candidate; a placement outside the patch lives in the base.
if (region.inlineManifest.empty() && (needed || propagate) && !region.files.empty()) {
if (region.inlineManifest.empty() && (needed || propagate || !isBase) && !region.files.empty()) {
std::string firstFailure;
constexpr std::uint32_t largestManifest = 32u << 20;
const auto expected = region.files.size() - 1;
@@ -568,6 +574,43 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(overridden.size()) +
" asset(s) take another mod's change, e.g. " + example);
}
// The mod's own EBX whose type-reference fixups sit on another fixup's slot, which crash
// the game as it loads (Engine/Resource/ebx_fixup_repair.h): the repaired copy goes into
// the merged patch. The one in the mod's folder, by where it lies there and its sha1, is
// still what a combine reads and compares.
std::map<std::size_t, fb::BundleFileInfo> repaired;
std::map<std::size_t, std::pair<fb::BundleFileInfo, fb::Sha1>> repairedFrom;
if (!isBase && (casBacked || !region.inlineManifest.empty())) {
auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest);
std::size_t removed{};
std::string example;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index) {
const auto at = (casBacked ? 1 : 0) + index;
if (at >= region.files.size() || overridden.contains(at)) continue;
const auto& file = region.files[at];
if (!file.location.patch) continue; // the game's own copy
auto& asset = manifest.ebx[index];
try {
auto encoded = store.read(root, file.location, file.offset, file.size);
const auto before = asset.sha1;
const auto count = repair_ebx_payload(encoded, asset, gameRoot);
if (!count) continue;
repaired[at] = store.write(file.location.installChunk, manifestArchive, encoded);
repairedFrom.emplace(at, std::pair{file, before});
removed += count;
if (example.empty()) example = asset.name;
} catch (const std::exception& error) {
report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name +
": its type references could not be checked (" + error.what() + ")");
}
}
if (casBacked) casManifest = std::move(manifest);
else if (!repaired.empty()) region.inlineManifest = fb::write_binary_bundle(manifest);
if (!repaired.empty())
report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(repaired.size()) +
" asset(s) listed " + std::to_string(removed) + " type reference(s) on another fixup's slot, "
"which crash the game as it loads; taken out of the merged copies, e.g. " + example);
}
// Assets another mod added to the game's copy of this bundle: this copy
// loads instead of the game's on this mod's levels, so it gets them too.
std::vector<Asset> additions;
@@ -615,7 +658,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
auto placed = carriedAt.find({installChunk, addition});
if (placed == carriedAt.end())
placed = carriedAt.emplace(std::pair{installChunk, addition},
store.write(installChunk, manifestArchive, addition->encoded)).first;
store.carry(installChunk, manifestArchive, addition->encoded)).first;
additions.push_back({addition->asset, placed->second});
addedFrom.insert(addition->mod);
} catch (const std::exception& error) {
@@ -629,7 +672,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
}
}
// Nothing moved in this bundle: it passes through whole, as before.
if (casBacked && !shared && !gridBundle && renumbered.empty() && overridden.empty() && additions.empty())
if (casBacked && !shared && !gridBundle && renumbered.empty() && overridden.empty() && repaired.empty() &&
additions.empty())
casBacked = false;
for (auto& file : region.files) {
@@ -641,7 +685,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// below moves every later file along, and the pass over the bundle's assets
// further down asks by where a file is then, not where it was.
std::vector<bool> inPatch(region.files.size());
for (const auto* moved : {&renumbered, &overridden})
for (const auto* moved : {&renumbered, &overridden, &repaired})
for (const auto& [index, file] : *moved) {
region.files[index] = file;
inPatch[index] = true;
@@ -748,8 +792,15 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// (the item collections every cosmetic mod adds its items to) fell back
// to one mod's copy.
const bool rewritten = firstFile + position < inPatch.size() && inPatch[firstFile + position];
if (!rewritten && (entry.asset.kind == fb::AssetKind::ebx ||
entry.asset.kind == fb::AssetKind::resource)) {
// A mod's copy that is the game's adds nothing: the game's copy is recorded, first.
const bool combinable = !rewritten && (entry.asset.kind == fb::AssetKind::ebx || entry.asset.kind == fb::AssetKind::resource);
if (const auto original = repairedFrom.find(firstFile + position); original != repairedFrom.end()) {
// Repaired above: what this mod changed is its copy as it ships it.
state.history[id].push_back({&root, original->second.first, original->second.second,
entry.asset.resourceMeta, isBase});
} else if (combinable && !isBase && state.matches_game_copy(id, entry.asset.sha1)) {
++report.unchangedCopies;
} else if (combinable) {
// The contributor's own copy still sits at its original offset in
// its own folder, which is where a later merge reads it from.
auto unshifted = entry.file;
@@ -757,7 +808,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
if (const auto* directory = store.find_directory(unshifted.location.installChunk))
unshift(*placement, *directory, unshifted.location.archive, unshifted.offset);
state.history[id].push_back(
{root, unshifted, entry.asset.sha1, entry.asset.resourceMeta, isBase});
{&root, unshifted, entry.asset.sha1, entry.asset.resourceMeta, isBase});
}
if (isBase) state.baseSha.insert_or_assign(id, entry.asset.sha1);
const auto at = state.seen.find(id);
@@ -788,10 +839,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// holds one asset per name, so the lower-priority mod's is gone,
// and whatever of that mod's still names it by guid finds nothing
// (an item list entry is dropped for that further down).
const bool shaderTable = entry.asset.kind == fb::AssetKind::resource &&
(entry.asset.resourceType == fb::shader::programLookupType ||
entry.asset.resourceType == fb::shader::textureLookupType);
if (entry.asset.kind != fb::AssetKind::chunk && !shaderTable && !rewritten && previous.mod &&
if (entry.asset.kind != fb::AssetKind::chunk && !rewritten && previous.mod &&
previous.mod != owner && previous.asset.sha1 != entry.asset.sha1 &&
!state.baseSha.contains(at->first)) {
auto& [count, example] = replaced[*previous.mod];
@@ -826,7 +874,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// base install, whichever mod's copy referenced it.
const auto decode = [&](const Contribution& from) {
return fb::decode_cas(
store.read(from.file.location.patch ? from.root : baseRoot,
store.read(from.file.location.patch ? *from.root : baseRoot,
from.file.location, from.file.offset, from.file.size),
{gameRoot});
};
@@ -849,11 +897,6 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const Contribution* baseCopy{};
for (const auto& contribution : found->second)
if (contribution.base) { baseCopy = &contribution; break; }
// Custom cosmetic bundles also carry the global shader tables,
// although the game's copy of that bundle does not. Use the
// highest-priority contributor as the starting table there.
const bool addedShaderTable = !baseCopy && shaderTable;
if (addedShaderTable) baseCopy = &found->second.back();
if (!baseCopy) continue;
// Both mods rebuild the whole bundle, so only the assets whose
// content actually moved away from the base are contested.
@@ -864,8 +907,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// An item list is looked at even when one mod alone changed it:
// what its entries name has to be in the bundle either way.
const auto named = entry.asset.kind == fb::AssetKind::ebx && item_list_name(entry.asset.name);
if (edits.size() < 2 && !(named && edits.size() == 1) &&
!(addedShaderTable && !edits.empty())) continue;
if (edits.size() < 2 && !(named && edits.size() == 1)) continue;
try {
const auto baseBytes = decode(*baseCopy);
std::vector<std::vector<std::byte>> editBytes;
@@ -894,12 +936,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
if (!count++) example = clash.path + " and " + clash.holder;
}
for (const auto& [index, clash] : shadowed)
report.notes.push_back(path_utf8(edits[index]->root.filename()) + ": " +
report.notes.push_back(path_utf8(edits[index]->root->filename()) + ": " +
std::to_string(clash.first) + " preset(s) share a short name with another mod's, e.g. " +
clash.second + "; they are registered by their full path only");
} else if (shaderTable) {
// Keep each private program and its backing arrays, then
// remap material rows into the combined program table.
// Each mod aliases new material keys onto shader programs
// and texture sets the base already ships, so the union of
// the rows they added is exactly right.
std::vector<fb::shader::Table> tables;
for (std::size_t index = 0; index < editBytes.size(); ++index)
tables.push_back({editBytes[index], edits[index]->resourceMeta});
@@ -907,6 +950,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
const auto table = entry.asset.resourceType == fb::shader::programLookupType
? fb::shader::merge_program_lookup(baseTable, tables)
: fb::shader::merge_texture_lookup(baseTable, tables);
if (table.outside)
report.notes.push_back(entry.asset.name + ": " + std::to_string(table.outside) +
" material row(s) left out: they point past the shader programs the game ships, "
"which the game would read outside its shader database");
if (!table.added) continue;
rebuilt = table.resource;
entry.asset.resourceMeta = table.resourceMeta;
@@ -954,7 +1001,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
fb::ebx::MergeSummary summary;
auto combined = fb::ebx::merge_documents(baseDocument, pointers, &summary,
itemList ? inBundle : notReplaced);
const auto mod_of = [&](std::size_t edit) { return path_utf8(edits[edit]->root.filename()); };
const auto mod_of = [&](std::size_t edit) { return path_utf8(edits[edit]->root->filename()); };
if (summary.dropped.empty() && !summary.instances && !summary.arrayEntries) {
// One mod's copy with nothing to leave out stays as that mod wrote it.
if (edits.size() < 2) continue;
@@ -1013,6 +1060,12 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
", which no enabled mod adds to the bundle"));
}
}
if (entry.asset.kind == fb::AssetKind::ebx)
if (auto repaired = fb::ebx::repair_type_fixups(rebuilt)) {
rebuilt = std::move(repaired->bytes);
report.notes.push_back(entry.asset.name + ": " + std::to_string(repaired->removed) +
" type reference(s) on another fixup's slot taken out of the merged copy");
}
const auto placement = store.write(state.manifestChunk, manifestArchive,
fb::encode_cas(rebuilt, {gameRoot}));
entry.file = placement;
+16
View File
@@ -1,6 +1,8 @@
#include "mod_merge_internal.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Platform/path_text.h"
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/ebx_fixup_repair.h"
#include <Windows.h>
#include <bcrypt.h>
@@ -142,6 +144,20 @@ fb::Sha1 sha1_of(std::span<const std::byte> bytes) {
return digest;
}
std::size_t repair_ebx_payload(std::vector<std::byte>& encoded, fb::BundleAsset& asset, const fs::path& gameRoot) {
std::optional<fb::ebx::FixupRepair> repaired;
try {
repaired = fb::ebx::repair_type_fixups(fb::decode_cas(encoded, {gameRoot}));
} catch (const std::exception&) {
return 0;
}
if (!repaired) return 0;
encoded = fb::encode_cas(repaired->bytes, {gameRoot});
asset.sha1 = sha1_of(repaired->bytes);
asset.originalSize = repaired->bytes.size();
return repaired->removed;
}
PlacementRecord read_placements(const fs::path& file) {
PlacementRecord record;
std::ifstream input(file, std::ios::binary);
+26 -3
View File
@@ -80,6 +80,12 @@ bool unshift(const ArchivePlacement& placement, std::string_view directory, std:
fb::Sha1 sha1_of(std::span<const std::byte> bytes);
// A mod's EBX as stored in cas, with the type-reference fixups that would crash the game's
// load taken out (Engine/Resource/ebx_fixup_repair.h): the payload is encoded again and the
// asset's sha1 and size follow it. How many came out; with none, or a payload that does not
// decode, both stay as they were.
std::size_t repair_ebx_payload(std::vector<std::byte>& encoded, fb::BundleAsset& asset, const fs::path& gameRoot);
// Where the launch's merge put every installed mod's archives, and which
// superbundle TOCs the patch holds: Mods/.reskate/reskate-placements.json.
inline constexpr wchar_t placements_file[] = L"reskate-placements.json";
@@ -132,11 +138,19 @@ public:
// own has been settled, settle() appends what it holds to the real archives, and
// settled() then moves a placement that points into it to where that went.
static constexpr std::uint16_t waiting_archive = 0xFFFF;
// A carried payload, by its index in the waiting store's list: settle() writes it once per install chunk.
static constexpr std::uint16_t carried_archive = 0xFFFE;
[[nodiscard]] CasStore waiting() const;
[[nodiscard]] bool holding() const noexcept { return !held_.empty(); }
[[nodiscard]] static bool waits(const fb::CasIdentifier& location) noexcept {
return location.patch && location.archive == waiting_archive;
[[nodiscard]] bool holding() const noexcept { return !held_.empty() || !carried_.empty(); }
[[nodiscard]] static bool placeholder(std::uint16_t archive) noexcept {
return archive == waiting_archive || archive == carried_archive;
}
[[nodiscard]] static bool placeholder(const fb::CasIdentifier& location) noexcept {
return location.patch && placeholder(location.archive);
}
// A waiting store only. The bytes must stay valid until settle().
[[nodiscard]] fb::BundleFileInfo carry(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded);
void settle(CasStore& waiting);
// True when the placement was one of this waiting store's; it now names the archive.
bool settled(fb::CasIdentifier& location, std::uint32_t& offset) const;
@@ -160,6 +174,15 @@ private:
bool waiting_{};
std::optional<std::uint16_t> archive_;
std::map<std::wstring, Held> held_;
// Carried payloads (waiting store) and where each landed (settling store).
struct Carried {
std::uint32_t installChunk{};
fb::Sha1 sha1;
std::span<const std::byte> bytes;
fb::BundleFileInfo at;
};
std::vector<Carried> carried_;
std::map<std::pair<std::uint32_t, fb::Sha1>, fb::BundleFileInfo> carriedAt_;
};
using ArchiveUse = std::set<std::pair<std::uint32_t, std::uint16_t>>;
+35 -14
View File
@@ -74,7 +74,8 @@ struct Change {
struct Script {
std::string name; // lower case
fb::Sha1 game; // the game's copy it replaces
AssetOverride value;
std::optional<AssetOverride> value; // none: it could not be read, and `note` says so
std::size_t note{}; // its place in the mod's notes
};
// What one mod's files say, read without regard to any other mod: the part of the work that
// is reading and decoding, done for several mods at once. What it means beside the mods
@@ -146,12 +147,18 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
// As the merge places it in the mod's own bundle: repaired when it would
// crash the game's load (repair_ebx_payload).
auto repairedAsset = asset;
const auto payload = [&] {
return store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size);
auto encoded = store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size);
if (file.location.patch) repair_ebx_payload(encoded, repairedAsset, gameRoot);
return encoded;
};
if (game_copy == original.end()) {
candidates.push_back({bundleName, {mod.name, asset, payload(), tocKey}});
auto encodedPayload = payload();
candidates.push_back({bundleName, {mod.name, repairedAsset, std::move(encodedPayload), tocKey}});
continue;
}
auto encodedPayload = payload();
@@ -163,8 +170,8 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({bundleName, name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
changes.push_back({bundleName, name, asset.name, game_copy->second.first, repairedAsset.sha1,
repairedAsset.originalSize, std::move(encodedPayload), gameFile});
}
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
@@ -176,14 +183,23 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto
// (A higher mod's version of the script, or this mod's own from another
// bundle, wins when the mods are settled: read here either way.)
if (std::ranges::any_of(scripts, [&](const Script& have) {
return have.name == name && have.game == originalScript->second->sha1; })) continue;
return have.name == name && have.game == originalScript->second->sha1 && have.value; })) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
scripts.push_back({name, originalScript->second->sha1,
AssetOverride{mod.name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size), asset}});
try {
scripts.push_back({name, originalScript->second->sha1,
AssetOverride{mod.name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size), asset}});
} catch (const std::exception& failed) {
// Read whether or not it will be wanted, so a failure is only this
// script's: where a higher mod's version stands, it is nobody's and
// the note goes (settled below). The rest of the TOC is read on.
notes.push_back(mod.name + ": " + relative +
": its changes could not be read for other mods' copies (" + failed.what() + ")");
scripts.push_back({name, originalScript->second->sha1, std::nullopt, notes.size() - 1});
}
}
// Resources the mod adds, kept to go with an added EBX of the same name
// (a wave's sound-bank resource registers the wave with the audio system).
@@ -273,7 +289,6 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
if (read.failure) std::rethrow_exception(read.failure);
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
std::size_t changed{};
report.notes.insert(report.notes.end(), std::make_move_iterator(notes.begin()), std::make_move_iterator(notes.end()));
// A higher mod's version of an asset, which this mod's change is combined with
// below, may name what this mod adds.
for (const auto& change : changes)
@@ -284,10 +299,16 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
} catch (const std::exception&) {}
for (auto& script : scripts) {
auto& versions = out.scripts[script.name];
if (versions.contains(script.game)) continue;
versions.emplace(script.game, std::move(script.value));
if (versions.contains(script.game)) {
if (!script.value) notes[script.note].clear(); // not wanted: as if never read
continue;
}
if (!script.value) continue; // wanted, and noted
versions.emplace(script.game, std::move(*script.value));
++changed;
}
for (auto& note : notes)
if (!note.empty()) report.notes.push_back(std::move(note));
// One addition per kind and name in a bundle, the highest-priority mod's. Two
// mods adding the same asset is no clash; two different assets under one name
// is, and only one of them can be what a copy gets: say whose, so a song or an
+6
View File
@@ -671,10 +671,16 @@ StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, st
work();
}
for (std::size_t index = 0; index < asked.size(); ++index) {
StoreCopies::Added keys{asked[index]->name, {}};
for (const auto& item : reads[index].items) keys.keys.push_back(lower(item.key));
std::sort(keys.keys.begin(), keys.keys.end());
keys.keys.erase(std::unique(keys.keys.begin(), keys.keys.end()), keys.keys.end());
result.added.push_back(std::move(keys));
scan.say(said[index]);
result.mod_meshes += reads[index].meshes.size();
if (!reads[index].items.empty() || !reads[index].meshes.empty()) added.push_back(std::move(reads[index]));
}
result.read = true;
}
if (added.empty()) return result;
// The costumes' meshes are only read when a mod ships a mesh at all.
+9
View File
@@ -139,6 +139,15 @@ struct StoreCopies {
StoreMeshes::Closest closest;
};
std::vector<MeshSeen> meshes_seen;
// Every item each mod adds or changes (lower-case keys), copies or not, in the catalogue's
// order (highest priority first): which mod an item is from (mod_item_sources.h). Empty for
// a mod with none; `read` false when the check could not run.
struct Added {
std::string mod;
std::vector<std::string> keys;
};
std::vector<Added> added;
bool read{};
// How many of the game's own items the mods' were held against; none when no mod adds an item.
std::size_t game_items{};
// And how many of the mods' meshes against how many of the game's costumes.
@@ -0,0 +1,647 @@
#include "Extension/Assets/map_download.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Launcher/mod_manager.h"
#include "Extension/UI/Overlay/overlay.h"
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <thread>
namespace fixture {
namespace fs = std::filesystem;
namespace policy = dingosdk::multiplayer::map_fetch_policy;
fs::path root;
std::atomic<std::uint64_t> clock{1000000};
constexpr auto use_download_deadline = static_cast<std::uint64_t>(-1);
std::atomic<std::uint64_t> active_deadline{use_download_deadline};
std::atomic<bool> downloading{}, installing{}, release_download{}, release_install{}, merging{};
std::atomic<bool> fetching{true}, held{}, observed_cancel{}, installed_cancel{};
std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{};
std::atomic<std::uint64_t> queued_stop_deadline{};
bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{};
dingosdk::MultiplayerModel session;
std::mutex messages_mutex, gate_mutex;
std::condition_variable gate;
std::vector<std::string> notices;
std::vector<std::uint64_t> http_budgets;
fs::path archive;
void require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
template<class Predicate>
void wait(Predicate predicate, const char* message) {
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!predicate()) {
if (std::chrono::steady_clock::now() >= end) throw std::runtime_error(message);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
bool noticed(std::string_view text) {
std::lock_guard lock(messages_mutex);
for (const auto& notice : notices)
if (notice.find(text) != std::string::npos) return true;
return false;
}
}
namespace dingosdk::multiplayer {
std::uint64_t fake_now_us() noexcept { return fixture::clock.load(); }
}
// Compile the actual lifecycle, not a parallel model. Preincluded headers keep these
// substitutions local to this translation unit; archives also stay in the test fixture.
DWORD test_get_temp_path(DWORD capacity, LPWSTR output) {
const auto path = fixture::root.wstring() + L"\\";
if (path.size() >= capacity) return 0;
std::copy(path.begin(), path.end(), output);
output[path.size()] = L'\0';
return static_cast<DWORD>(path.size());
}
#define now_us fake_now_us
#define GetTempPathW test_get_temp_path
#include "../map_download.cpp"
#undef GetTempPathW
#undef now_us
namespace dingosdk::https {
std::optional<std::string> get_text(std::wstring_view, std::uint64_t, std::uint32_t,
const wchar_t*, Download* result, Watch* watch) {
fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(),
"metadata shares the session deadline");
if (result) *result = {true, 200, 0};
return R"({"namespace":"Team","name":"Map","community_listings":[{"community":"reskate","categories":["Maps"]}],
"latest":{"version_number":"2.0.0","description":"Test map"}})";
}
Download get(std::wstring_view, const std::filesystem::path& destination,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t*, Watch* watch) {
using namespace fixture;
require(max_bytes == (8ull << 30), "size restriction retained");
require(timeout_seconds == 4 * 60 * 60, "HTTP keeps its own four hour limit, not the session's");
archive = destination;
const auto request = ++downloads;
http_budgets.push_back(watch->deadline_us.load());
std::ofstream(destination, std::ios::binary) << "partial archive";
watch->received.store(10);
watch->total.store(100);
downloading.store(true);
if (block_download) {
std::unique_lock lock(gate_mutex);
gate.wait(lock, [] { return release_download.load(); });
}
if (slow) {
// Real download progress still cannot extend either absolute deadline.
fixture::clock.fetch_add(10 * 60 * policy::second_us);
watch->received.store(90);
}
observed_cancel.store(watch->cancel.load());
if (watch->cancel.load()) return {false, 0, ERROR_CANCELLED};
if (fallback && request == 1) {
fixture::clock.fetch_add(60 * policy::second_us);
return {false, 404, 0};
}
if (transfer_error) return {false, 0, ERROR_WINHTTP_TIMEOUT};
watch->received.store(100);
return {true, 200, 0};
}
}
namespace dingosdk::mods {
std::filesystem::path engine_data_root() noexcept { return fixture::root; }
ModList scan_mods(const std::filesystem::path&) noexcept { return {}; }
void save_mod_order(const std::filesystem::path&, const std::vector<ModEntry>&) {}
}
namespace dingosdk::launcher_mods {
std::string install(const std::filesystem::path& root, const std::filesystem::path& source, bool,
const Progress& progress, const std::atomic<bool>& cancel, const InstallOptions& options) {
using namespace fixture;
require(fs::exists(source), "installer receives completed archive");
++installs;
installing.store(true);
if (block_install) {
std::unique_lock lock(gate_mutex);
gate.wait(lock, [] { return release_install.load(); });
}
progress(0.5f);
installed_cancel.store(cancel.load());
if (cancel.load()) throw std::runtime_error("cancelled");
fs::create_directories(root / options.folder);
std::ofstream(root / options.folder / "reskate-levels.json") << "{}";
return options.folder;
}
void remove(const std::filesystem::path&, const std::string&) {}
}
namespace dingosdk::live_mods {
bool only_maps(const std::filesystem::path&) { return true; }
bool busy() noexcept { return fixture::merging.load(); }
std::string apply(bool reload) {
fixture::require(!reload, "map apply must not reload prematurely");
++fixture::applies;
fixture::merging.store(true);
return "applying";
}
Progress progress() { return {1, 2, "map"}; }
std::vector<std::string> applied_mods() { return {"Team-Map"}; }
std::string status() { return "applied"; }
}
namespace dingosdk::multiplayer {
MultiplayerModel model() { return fixture::session; }
bool fetching_map() noexcept { return fixture::fetching.load(); }
std::uint64_t map_fetch_deadline() noexcept {
const auto active = fixture::active_deadline.load();
return active == fixture::use_download_deadline ? map_download::deadline.load() : active;
}
void hold_map_load(bool hold, std::string_view package) noexcept {
fixture::held.store(hold);
if (hold && package != "Team-Map-1.0.0") std::abort();
}
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
if (action == "stop") ++fixture::stops;
if (action == "stop-map-fetch") {
const auto expected = map_download::deadline.load();
fixture::require(expected && argument == std::to_string(expected) && password.empty(),
"map stop carries the captured originating attempt deadline");
fixture::queued_stop_deadline.store(expected);
++fixture::stops;
}
if (action == "rejoin") ++fixture::rejoins;
return true;
}
std::string map_package_name(std::string_view folder, std::string_view version) {
return std::string(folder) + "-" + std::string(version);
}
}
namespace dingosdk::overlay {
void set_map_download_icon(const std::string&) noexcept {}
void notify(NoticeLevel, std::string title, std::string text) noexcept {
std::lock_guard lock(fixture::messages_mutex);
fixture::notices.push_back(title + ": " + text);
}
}
namespace dingosdk::logging {
bool enabled(Level) noexcept { return false; }
void write(Level, Context, Channel, std::string_view) noexcept {}
void write(Level, Channel, std::string_view) noexcept {}
}
namespace fixture {
namespace download = dingosdk::map_download;
using Stage = download::Stage;
void drain() {
release_download.store(true);
release_install.store(true);
gate.notify_all();
wait([] { return download::workers.load() == 0; }, "workers did not drain");
}
void reset() {
drain();
require(download::view().stage == Stage::idle, "previous lifecycle did not finish");
fs::remove_all(root / "Mods");
clock.store(1000000);
active_deadline.store(use_download_deadline);
downloading.store(false);
installing.store(false);
release_download.store(false);
release_install.store(false);
merging.store(false);
fetching.store(true);
held.store(false);
observed_cancel.store(false);
installed_cancel.store(false);
downloads.store(0);
installs.store(0);
applies.store(0);
stops.store(0);
queued_stop_deadline.store(0);
rejoins.store(0);
block_download = block_install = slow = transfer_error = fallback = false;
session = {};
session.active = true;
session.connected = false;
notices.clear();
http_budgets.clear();
archive.clear();
download::last_failed.clear();
}
void offer(std::uint64_t deadline = 0, bool update = false) {
dingosdk::multiplayer::MapNeed need{"Team-Map-1.0.0", "Test map", true, false,
update ? "0.9.0" : "", deadline};
download::offer(need);
wait([] { return download::asking(); }, "metadata did not reach confirmation");
wait([] { return download::workers.load() == 0; }, "metadata worker did not drain");
}
void start_apply() {
wait([] { return download::view().stage == Stage::applying; }, "did not install and reach applying");
drain();
download::tick();
require(applies.load() == 1 && held.load(), "apply starts once and holds session loading");
}
void applying() {
download::answer(true);
start_apply();
}
void joined() {
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "completed merge advances to joining");
fetching.store(false);
session.connected = true;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle, "joined session clears lifecycle");
require(!held.load() && stops.load() == 0 && rejoins.load() == 0, "normal join does not leave or rejoin");
}
void idle(const char* reason, unsigned expected_stops = 1) {
require(download::view().stage == Stage::idle, reason);
drain();
require(stops.load() == expected_stops, "failure only stops its originating waiting session");
if (expected_stops)
require(queued_stop_deadline.load() == download::deadline.load(),
"queued map stop identifies the originating attempt for dispatch-time ownership validation");
download::tick();
require(held.load() == (merging.load() && download::apply_started.load()),
"failure holds map loading only while an uncancellable live merge drains");
require(archive.empty() || !fs::exists(archive), "partial archive removed");
}
void tests() {
reset();
offer();
applying();
require(downloads.load() == 1 && installs.load() == 1, "success downloads and installs exactly once");
joined();
reset();
merging.store(true);
offer();
download::answer(true);
wait([] { return download::view().stage == Stage::applying; }, "unrelated live merge blocked map offer");
drain();
download::tick();
require(applies.load() == 0 && held.load(), "map apply waits for unrelated live merge without rejecting offer");
merging.store(false);
download::tick();
require(applies.load() == 1, "map apply starts after unrelated merge drains");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start for duplicate confirmation");
clock.fetch_add(policy::second_us);
download::answer(true);
require(downloads.load() == 1 && download::workers.load() == 1, "only winning confirmation claims a worker");
release_download.store(true);
gate.notify_all();
start_apply();
joined();
reset();
block_download = true;
slow = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "slow download did not start");
clock.fetch_add(10 * 60 * policy::second_us);
download::watch.received.store(50);
download::tick();
require(download::view().stage == Stage::downloading && download::view().received == 50 && held.load(),
"progressing transfer remains active beyond old five minute deadline");
release_download.store(true);
gate.notify_all();
start_apply();
require(clock.load() > 5 * 60 * policy::second_us, "slow progressing transfer exceeds former five minute limit");
joined();
reset();
transfer_error = true;
offer();
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "HTTP timeout did not fail");
idle("transfer timeout");
require(installs.load() == 0 && noticed("timed out"), "HTTP timeout cannot install");
transfer_error = false;
stops.store(0);
offer();
applying();
require(downloads.load() == 2, "timeout allows same package retry");
joined();
// The session stops waiting (its deadline passes and it ends itself) while the map downloads:
// the download is not thrown away, the map installs and is applied, and the player is told to
// join again (the session would not take them back past its deadline).
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start");
clock.store(download::deadline.load());
session.active = false;
fetching.store(false);
active_deadline.store(0);
download::tick();
require(download::view().stage == Stage::downloading && !download::watch.cancel.load() && noticed("still downloading"),
"session expiry leaves an active download running and says so");
release_download.store(true);
gate.notify_all();
start_apply();
require(!observed_cancel.load() && installs.load() == 1, "download that outlived its session installs");
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "detached download is applied");
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle && rejoins.load() == 0 && stops.load() == 0 && noticed("Map installed"),
"detached download ends installed, without rejoining an expired session");
reset();
block_install = true;
offer();
download::answer(true);
wait([] { return installing.load(); }, "install did not start");
clock.store(download::deadline.load());
download::tick();
drain();
require(!installed_cancel.load() && installs.load() == 1, "session expiry does not cancel an install under way");
wait([] { return download::view().stage == Stage::applying; }, "expired install did not go on to applying");
download::tick();
require(applies.load() == 1, "expired install is still applied");
merging.store(false);
download::tick();
fetching.store(false);
session.active = false;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle && rejoins.load() == 0 && noticed("Map installed"),
"map installed after the session's deadline is not rejoined");
// A live merge cannot be stopped: a fresh need waits for one left behind by a cancelled attempt.
reset();
offer();
applying();
active_deadline.store(download::deadline.load() + 1); // another session's own attempt
download::tick();
idle("apply cancelled by another session", 0);
require(merging.load() && rejoins.load() == 0, "cancelled atomic merge must not join");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(download::view().stage == Stage::idle && noticed("still finishing"), "busy live merge blocks retry");
merging.store(false);
download::tick();
require(rejoins.load() == 0 && !held.load(), "finished cancelled apply cannot rejoin and releases load hold");
reset();
offer();
download::answer(false);
idle("decline");
require(downloads.load() == 0 && noticed("cancelled"), "decline never downloads");
reset();
offer();
clock.store(download::ask_until.load() - 1);
download::tick();
require(download::asking(), "confirmation remains open one microsecond before deadline");
clock.fetch_add(1);
download::answer(true);
idle("confirmation exact boundary");
require(downloads.load() == 0 && noticed("two minutes"), "late click cannot bypass confirmation timeout");
reset();
offer();
clock.store(download::ask_until.load());
download::tick();
idle("confirmation tick timeout");
require(downloads.load() == 0 && noticed("two minutes"), "tick expires unanswered prompt");
reset();
const auto short_session = clock.load() + policy::confirmation_us / 2;
offer(short_session);
require(download::ask_until.load() == short_session, "confirmation is capped by existing session deadline");
clock.store(short_session);
download::answer(true);
idle("shorter session confirmation boundary");
require(downloads.load() == 0 && noticed("timed out"), "confirmation never extends existing session budget");
reset();
offer(clock.load() + 1);
download::answer(true);
wait([] { return download::view().stage == Stage::applying; }, "download confirmed in the session's last microsecond did not start");
require(downloads.load() == 1 && http_budgets[0] == 0, "a download confirmed in time starts, with no session deadline on it");
drain();
download::tick();
merging.store(false);
download::tick();
fetching.store(false);
session.active = false;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle && noticed("Map installed"), "late download still ends installed");
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "HTTP did not start for cancel race");
download::answer(false);
require(download::watch.cancel.load(), "cancel flag is immediate");
download::tick();
require(held.load(), "cancelled HTTP keeps map loading held until worker drains");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(downloads.load() == 1 && noticed("still finishing"), "retry cannot reuse a draining HTTP worker");
drain();
require(observed_cancel.load() && !fs::exists(archive) && installs.load() == 0, "cancel cleans archive without install");
// The same package is retriable after a user cancellation.
block_download = false;
offer();
applying();
require(downloads.load() == 2, "retry fetches same package again");
// The original user cancellation stopped its waiting attempt.
stops.store(0);
joined();
reset();
fallback = true;
offer();
applying();
require(downloads.load() == 2 && http_budgets.size() == 2, "404 selects newest fallback");
require(http_budgets[0] == 0 && http_budgets[1] == 0, "neither request is held to the session's deadline");
joined();
reset();
fallback = true;
offer(0, true);
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "missing host version did not fail");
idle("host version consistency");
require(downloads.load() == 1 && installs.load() == 0, "version mismatch never falls back to another host version");
reset();
offer();
const auto original_deadline = download::deadline.load();
download::offer({"Team-Other-1.0.0", "Other map", true});
require(download::asking() && download::deadline.load() == original_deadline && stops.load() == 1,
"host changing maps does not replace in-flight request or extend its deadline");
stops.store(0);
download::answer(false);
idle("host map change");
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start for session replacement");
active_deadline.store(0);
download::tick();
idle("session identity changed", 0);
require(observed_cancel.load() && installs.load() == 0,
"download cancels when the active session no longer owns its deadline");
reset();
offer();
active_deadline.store(0);
fetching.store(false);
session.connected = true;
download::tick();
require(download::view().stage == Stage::idle && !held.load() && downloads.load() == 0 &&
stops.load() == 0 && rejoins.load() == 0,
"abandoned confirmation cancels without holding or stopping a fresh unrelated ordinary join");
download::answer(true);
require(downloads.load() == 0, "late confirmation cannot start download for abandoned session");
reset();
offer();
const auto abandoned_deadline = download::deadline.load();
clock.fetch_add(policy::second_us);
const auto fresh_deadline = clock.load() + policy::session_us;
active_deadline.store(fresh_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", fresh_deadline});
wait([] { return download::asking(); }, "fresh same-package need was not retaken");
drain();
require(download::deadline.load() == fresh_deadline && fresh_deadline != abandoned_deadline && stops.load() == 0,
"fresh same-package attempt replaces stale confirmation without stopping its new session");
applying();
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "old download did not start for queued replacement");
clock.fetch_add(policy::second_us);
const auto queued_deadline = clock.load() + policy::session_us;
active_deadline.store(queued_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", queued_deadline});
download::tick();
require(download::view().stage == Stage::idle && download::watch.cancel.load() &&
downloads.load() == 1 && stops.load() == 0 && download::pending &&
download::pending->deadline_us == queued_deadline,
"fresh offer queues while cancelled worker drains without stopping replacement session");
drain();
require(observed_cancel.load() && installs.load() == 0 && !fs::exists(archive),
"old worker cleans partial archive before queued replacement can use its globals");
block_download = false;
download::tick();
wait([] { return download::asking(); }, "queued same-package need was not offered after drain");
drain();
require(download::deadline.load() == queued_deadline && !download::pending && stops.load() == 0,
"queued attempt keeps its fresh deadline");
applying();
require(downloads.load() == 2, "queued fresh attempt starts exactly one new transfer");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "old download did not start for stale pending need");
clock.fetch_add(policy::second_us);
const auto superseded_deadline = clock.load() + policy::session_us;
active_deadline.store(superseded_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", superseded_deadline});
active_deadline.store(0);
drain();
download::tick();
require(download::view().stage == Stage::idle && !download::pending && downloads.load() == 1 &&
stops.load() == 0 && !held.load(),
"queued need is discarded when its replacement session also ends before worker drain");
reset();
offer();
applying();
merging.store(false);
download::tick();
clock.store(download::join_at.load() + policy::installed_load_us - 1);
download::tick();
require(download::view().stage == Stage::joining, "installed map load retains its last microsecond");
clock.fetch_add(1);
download::tick();
idle("installed map load timeout");
require(noticed("still cannot load"), "installed map reports load failure at 30 seconds");
reset();
offer();
applying();
merging.store(false);
download::tick();
fetching.store(false);
session.active = false;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle && rejoins.load() == 1 && stops.load() == 0,
"successful install rejoins when the original session ended independently");
reset();
offer();
applying();
clock.store(download::deadline.load() - 2);
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "near-deadline apply reaches joining");
clock.fetch_add(1);
session.connected = true;
fetching.store(false);
active_deadline.store(0);
clock.fetch_add(2);
download::tick();
require(download::view().stage == Stage::idle && !held.load() && stops.load() == 0 &&
rejoins.load() == 0 && !noticed("timed out"),
"join completed within session budget retires successfully even when next tick is after deadline");
reset();
download::tick();
require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0,
"ordinary join without download is untouched");
}
}
int main(int argc, char** argv) {
try {
fixture::require(argc == 2, "pass a fixture directory");
fixture::root = std::filesystem::absolute(argv[1]);
std::filesystem::create_directories(fixture::root);
fixture::tests();
fixture::drain();
std::filesystem::remove_all(fixture::root);
std::cout << "Production map download timeout lifecycle tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
// A bounded wait failure can leave detached production workers alive. Do not
// destroy their fixture globals while they may still be using them.
std::_Exit(1);
}
}
+3
View File
@@ -8,6 +8,7 @@
#include "Engine/Core/Platform/path_text.h"
#include "Engine/Game/Build/20260929/live_mods.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Vfs/builtin_packs.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_scoring.h"
#include "Extension/World/loading_screen.h"
@@ -508,6 +509,8 @@ ApplyEffect preview(const mods::ModList &list) {
// One of a Board Break deck pack's decks stands for the pack (mod_catalog.h).
if (const auto pack = mods::board_break_pack_of(name); named(now, pack.empty() ? name : pack)) continue;
const auto *entry = entry_of(name);
// Content ReSkate keeps itself (builtin_packs.h) is in no mod list, and stays.
if (!entry && (name.starts_with("reskate-builtin-") || mods::kept_by_reskate(list.root, name))) continue;
// A folder deleted since: what it held is unknown, so assume the worst.
if (!entry) {
reload = true;
+257 -66
View File
@@ -6,15 +6,18 @@
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/UI/Overlay/overlay.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <atomic>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
#include <utility>
namespace dingosdk::map_download {
namespace {
@@ -24,13 +27,21 @@ namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr char community[] = "reskate";
// A map is at most a few gigabytes (an archive of one holds four); the download may take as
// long as a slow line needs for that.
// long as a slow line needs for that, whether or not the session is still waiting on it.
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
namespace policy = multiplayer::map_fetch_policy;
std::mutex mutex;
std::atomic<Stage> stage{Stage::idle};
std::atomic<unsigned> workers{};
std::atomic<std::uint64_t> deadline{}, ask_until{};
// The session stopped waiting while the map was coming (its deadline passed, the server gave up
// on the player, the connection went): the map is fetched and installed all the same, and the
// player is told to join again.
std::atomic<bool> detached{};
multiplayer::MapNeed need; // under mutex, while not idle
std::optional<multiplayer::MapNeed> pending; // a fresh attempt waiting for cancelled work to drain
package::Name wanted; // under mutex
std::string version; // under mutex: the one to download
std::string newest; // under mutex
@@ -65,34 +76,107 @@ bool need_server() {
}
// The session that is waiting on the map is left: there is no map to wait for.
void leave_session() {
if (multiplayer::fetching_map()) multiplayer::queue_command("stop", "", "");
void leave_session(std::uint64_t expected = 0) {
const auto owned = expected ? expected : multiplayer::map_fetch_deadline();
if (multiplayer::fetching_map() && owned)
multiplayer::queue_command("stop-map-fetch", std::to_string(owned), "");
}
// Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason) {
void fail(std::string reason, bool retry = false) {
std::string text;
bool update{};
std::uint64_t failed_deadline{};
{
std::lock_guard lock(mutex);
if (stage.exchange(Stage::idle) == Stage::idle) return;
failed_deadline = deadline.load();
watch.cancel.store(true);
stop_install.store(true);
apply_wanted.store(false);
update = !need.have.empty();
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
(update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). "
: ", which is not installed on this PC. ") + reason;
last_failed = need.package;
if (!retry) last_failed = need.package;
}
stage.store(Stage::idle);
leave_session();
if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline);
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text);
overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text));
}
// The packages of the maps installed here, by level (package_of): forgotten when one is installed.
struct Known {
std::string package;
std::uint64_t until{};
// Whether the download has begun (or is done): from then on it goes to the end on its own time.
bool under_way(Stage now) {
return now == Stage::downloading || now == Stage::installing || now == Stage::applying || now == Stage::joining;
}
bool continuing() {
const auto now = stage.load();
if (now == Stage::idle) return false;
// The session's deadline ends a check or a question it outlived; a download under way is
// not thrown away with it (a large map on a slow line would never come at all).
if (!under_way(now) && policy::expired(deadline.load(), multiplayer::now_us())) {
fail("Map download timed out. Please try joining the server again.", true);
return false;
}
return !watch.cancel.load();
}
bool advance(Stage from, Stage to) {
if (!continuing()) return false;
return stage.compare_exchange_strong(from, to);
}
void ask() {
ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us));
advance(Stage::checking, Stage::asking);
}
// The packages of the maps installed here, by level (lower case), read from the Mods folder in
// one pass (package_of). Forgotten when a map is installed; read again in the background two
// minutes after it was read. It was read for each level asked, on the client thread, with every
// map offer a host sends (a guest joining, a map change): tens of milliseconds, a second and more
// with many mods (2026-10-15).
struct KnownPackages {
std::map<std::string, std::string, std::less<>> by_level;
std::uint64_t read_at{};
bool valid{}, reading{};
};
std::mutex known_mutex;
std::map<std::string, Known, std::less<>> known_packages;
KnownPackages known_packages;
std::map<std::string, std::string, std::less<>> read_known_packages() {
std::map<std::string, std::string, std::less<>> table;
const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries) {
if (!entry.enabled || !entry.mod.provides_levels) continue;
const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
for (auto level : entry.mod.levels) {
std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
table.emplace(std::move(level), package); // (the first mod that has a level is the one)
}
}
return table;
}
// Reads the table on a thread of its own when there is none, or it is two minutes old. Under
// known_mutex.
void refresh_known_packages(std::uint64_t now) {
if (known_packages.reading || (known_packages.valid && now - known_packages.read_at < 120000)) return;
known_packages.reading = true;
try {
std::thread([] {
std::map<std::string, std::string, std::less<>> table;
bool ok{};
try { table = read_known_packages(); ok = true; } catch (...) {}
std::lock_guard lock(known_mutex);
if (ok) {
known_packages.by_level = std::move(table);
known_packages.read_at = GetTickCount64();
known_packages.valid = true;
}
known_packages.reading = false;
}).detach();
} catch (...) { known_packages.reading = false; }
}
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it
// says of itself.
@@ -130,11 +214,12 @@ void check() {
const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024);
overlay::set_map_download_icon(bytes);
} catch (const std::exception&) {}
stage.store(Stage::asking);
ask();
return;
}
https::Download result;
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result);
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch);
if (!continuing()) return;
if (!answer)
return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again."
: "Thunderstore could not be reached to look for it.");
@@ -151,9 +236,9 @@ void check() {
}
// Its icon for the card: small, and not worth holding the question up for long.
if (!choice.icon.empty())
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent))
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch))
overlay::set_map_download_icon(*png);
stage.store(Stage::asking);
ask();
} catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what());
}
@@ -175,6 +260,7 @@ void fetch() {
const auto root = mods_root();
const auto folder = name.folder();
try {
if (!continuing()) return;
if (!off) {
wchar_t temp[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, temp);
@@ -184,41 +270,54 @@ void fetch() {
fs::remove(archive, error);
watch.received.store(0);
watch.total.store(0);
auto result = https::get(package::download_url(name, get), archive, largest_download, download_seconds, agent, &watch);
// (The session's deadline is not the download's: only its own time limit stops it.)
const auto download = [&](const std::string& selected) {
watch.deadline_us.store(0);
return https::get(package::download_url(name, selected), archive, largest_download, download_seconds, agent, &watch);
};
auto result = download(get);
// The host's version can be gone from Thunderstore: the newest is the same map.
if (!update && !result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) {
fs::remove(archive, error);
logging::log(logging::Level::info, logging::Channel::assets,
"Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback);
result = https::get(package::download_url(name, fallback), archive, largest_download, download_seconds, agent, &watch);
result = download(fallback);
}
if (!result.ok) {
fs::remove(archive, error);
if (watch.cancel.load()) { stage.store(Stage::idle); leave_session(); return; }
if (!continuing()) return;
if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT)
return fail("The download timed out. Please try joining the server again.", true);
return fail(result.http_status && result.http_status != 200
? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")."
: "The download failed (Windows error " + std::to_string(result.error) + ").");
}
stage.store(Stage::installing);
if (!advance(Stage::downloading, Stage::installing)) {
fs::remove(archive, error);
return;
}
try {
// (In the installed one's place when it is the same map at another version. The
// game may hold the old one's files open when it was skated this run: then it
// cannot be replaced until the game is closed.)
launcher_mods::install(root, archive, update, [](float) {}, stop_install, {folder, name.owner, true});
launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true});
} catch (const std::exception& failure) {
fs::remove(archive, error);
if (!continuing()) return;
return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() +
"). Close the game, update it from the launcher and join again."
: std::string("It could not be installed: ") + failure.what());
}
fs::remove(archive, error);
if (!continuing()) return;
{
std::lock_guard known(known_mutex);
known_packages.clear();
known_packages.valid = false;
}
} else {
stage.store(Stage::installing);
if (!advance(Stage::downloading, Stage::installing)) return;
}
if (!continuing()) return;
// A map and nothing else: this way in is for maps.
std::error_code error;
if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) {
@@ -228,16 +327,18 @@ void fetch() {
return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it.");
}
if (off || update) { // (a map at another version may have been switched off: it is wanted now)
if (!continuing()) return;
auto list = mods::scan_mods(mods::engine_data_root());
if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue);
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = true;
mods::save_mod_order(list.root, list.entries);
}
if (!continuing()) return;
logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder);
apply_started.store(false);
apply_wanted.store(true);
stage.store(Stage::applying);
if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false);
} catch (const std::exception& failure) {
fail(std::string("It could not be installed: ") + failure.what());
}
@@ -245,10 +346,37 @@ void fetch() {
// One piece of work at a time, which the stage sees to: each is started by the move into its
// stage. Detached, as the live merge's thread is: the game may close while a map downloads.
void start(void (*work)()) { std::thread(work).detach(); }
void start(void (*work)()) {
++workers;
try {
std::thread([work] {
struct Finished { ~Finished() { --workers; } } finished;
work();
}).detach();
} catch (...) {
--workers;
throw;
}
}
} // namespace
void offer(const multiplayer::MapNeed& asked) {
std::unique_lock lock(mutex);
// (Another session's need cancels this attempt, unless its own session has stopped waiting
// on it already: then it finishes, and the new need is turned away below.)
if (stage.load() != Stage::idle && !detached.load() && asked.deadline_us && asked.deadline_us != deadline.load()) {
lock.unlock();
fail("Map download cancelled.", true);
lock.lock();
}
// A cancelled worker/atomic live merge must drain before its globals or files are reused.
if (stage.load() == Stage::idle && (workers.load() || (apply_started.load() && live_mods::busy()))) {
pending = asked;
overlay::notify(overlay::NoticeLevel::warning, "Map download",
"The previous map operation is still finishing. Waiting before starting this map.");
return;
}
pending.reset();
auto idle = Stage::idle;
const auto name = package::parse_name(asked.package);
// (Taken before anything is set up, so a second need while this one is in hand is dropped.)
@@ -256,10 +384,7 @@ void offer(const multiplayer::MapNeed& asked) {
// Another map is in hand (the session moved on while it came). One at a time: the one
// being fetched is finished, and the session, which now waits on this other one, is left.
bool other{};
{
std::lock_guard lock(mutex);
other = asked.package != need.package;
}
other = asked.package != need.package;
if (other) {
overlay::notify(overlay::NoticeLevel::warning, "Map changed again",
"The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done.");
@@ -268,7 +393,6 @@ void offer(const multiplayer::MapNeed& asked) {
return;
}
{
std::lock_guard lock(mutex);
need = asked;
if (!name || asked.package == last_failed) {
// (Said once already, or nothing this can fetch: the plain notice.)
@@ -287,23 +411,44 @@ void offer(const multiplayer::MapNeed& asked) {
}
overlay::set_map_download_icon({});
watch.cancel.store(false);
const auto now = multiplayer::now_us();
deadline.store(asked.deadline_us ? asked.deadline_us : now + policy::session_us);
ask_until.store(0);
detached.store(false);
watch.deadline_us.store(deadline.load());
stop_install.store(false);
apply_wanted.store(false);
apply_started.store(false);
pick_download.store(true);
start(check);
try {
start(check);
} catch (...) {
lock.unlock();
fail("The map download could not be started. Please try joining again.", true);
}
}
void answer(bool download) noexcept {
try {
// (A click and a key can answer in the same moment: only the first does.)
auto asked = Stage::asking;
if (stage.compare_exchange_strong(asked, download ? Stage::downloading : Stage::idle)) {
if (download) start(fetch);
else leave_session();
} else if (asked == Stage::downloading && !download) {
watch.cancel.store(true);
if (!continuing()) return;
const auto now = multiplayer::now_us();
if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) {
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
return;
}
if (!download) {
if (stage.load() == Stage::asking || stage.load() == Stage::downloading)
fail("Map download cancelled.", true);
} else {
// Publish the stage and claim its worker together. Cancellation cannot allow
// a fresh offer to reuse the globals before this thread starts its worker.
std::lock_guard lock(mutex);
auto asked = Stage::asking;
if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) start(fetch);
}
} catch (...) {
stage.store(Stage::idle);
fail("The map download could not be started. Please try joining again.", true);
}
}
@@ -345,10 +490,47 @@ View view() {
}
void tick() {
const auto now = stage.load();
warm_packages();
std::optional<multiplayer::MapNeed> next;
{
std::lock_guard lock(mutex);
if (pending && pending->deadline_us != multiplayer::map_fetch_deadline()) pending.reset();
if (pending && stage.load() == Stage::idle && !workers.load() &&
!(apply_started.load() && live_mods::busy()))
next = std::exchange(pending, std::nullopt);
}
if (next) offer(*next);
auto now = stage.load();
if (now != Stage::idle) {
const auto session = multiplayer::model();
// Admission (or completed travel) retires the session deadline. Do not turn an
// already successful join into a timeout while waiting out the UI's settling delay.
if (now == Stage::joining && session.active && session.connected &&
!multiplayer::fetching_map() && !multiplayer::map_fetch_deadline()) {
std::lock_guard lock(mutex);
if (stage.compare_exchange_strong(now, Stage::idle)) last_failed.clear();
now = stage.load();
}
const bool gave_up = policy::expired(deadline.load(), multiplayer::now_us()) ||
session.status.find("stopped waiting for the map download") != std::string::npos;
// The session stopped waiting while the download was under way: it finishes all the same.
if (!session.active && under_way(now) && !detached.exchange(true) && gave_up)
overlay::notify(overlay::NoticeLevel::warning, "Map still downloading",
"The " + [] { std::lock_guard lock(mutex); return host_word(need.server); }() +
" stopped waiting for the map. It goes on downloading and installs when it is done; join again then.");
if (session.active && !detached.load() && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load())
fail("Map download cancelled.", true);
if (!session.active && !under_way(now) && gave_up)
fail("The server stopped waiting for the map download.", true);
if (continuing() && now == Stage::asking &&
policy::expired(ask_until.load(), multiplayer::now_us()))
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
now = stage.load();
}
// The session leaves the map alone until it has been applied (an update's level is one the
// game already has, and would load from files that are being replaced).
multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying);
multiplayer::hold_map_load((now != Stage::idle && now != Stage::joining) ||
workers.load() || (apply_started.load() && live_mods::busy()), need_package());
if (now == Stage::applying) {
if (apply_wanted.load()) {
// (A merge of the player's own may be running: wait for it and start this one.)
@@ -371,50 +553,59 @@ void tick() {
return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ").");
// The level list takes the new map on the next ticks, and the session, which has been
// trying the map all along, loads it then.
join_at.store(GetTickCount64());
stage.store(Stage::joining);
join_at.store(multiplayer::now_us());
advance(Stage::applying, Stage::joining);
return;
}
if (now == Stage::joining) {
const auto waited = GetTickCount64() - join_at.load();
if (waited < 2500) return;
const auto waited = multiplayer::now_us() - join_at.load();
if (waited < 2500000) return;
// Still waiting on it: the game has the mod and cannot find the map in it.
if (multiplayer::fetching_map()) {
if (waited > 30000) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
const auto session = multiplayer::model();
if (multiplayer::fetching_map() || (session.active && !session.connected)) {
if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
return;
}
{
// Should it not load after all, it is said and not offered round and round.
std::lock_guard lock(mutex);
last_failed = need.package;
last_failed.clear();
}
stage.store(Stage::idle);
// The session ended while the map came (the host left, the connection dropped): back to it.
if (!multiplayer::model().active && !multiplayer::queue_command("rejoin", "", ""))
overlay::notify(overlay::NoticeLevel::warning, "Map installed", "Join the session again from the server browser.");
if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return;
// The session ended while the map came (the host left, the connection dropped): back to it,
// while it would still have the player. Past its deadline it would not.
if (!multiplayer::model().active &&
(policy::expired(deadline.load(), multiplayer::now_us()) || !multiplayer::queue_command("rejoin", "", "")))
overlay::notify(overlay::NoticeLevel::info, "Map installed", "Join the session again from the server browser.");
}
}
void warm_packages() noexcept {
try {
std::lock_guard lock(known_mutex);
if (!known_packages.valid) refresh_known_packages(GetTickCount64());
} catch (...) {}
}
std::string package_of(std::string_view asset) {
auto& cache_mutex = known_mutex;
auto& cache = known_packages;
if (asset.empty()) return {};
std::string key(asset);
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
const auto now = GetTickCount64();
std::lock_guard lock(cache_mutex);
if (const auto found = cache.find(key); found != cache.end() && now < found->second.until) return found->second.package;
// Asked with every map offer a host sends: the Mods folder is read at most every two
// minutes for a level.
std::string result;
const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries) {
if (!entry.enabled || !entry.mod.provides_levels) continue;
if (std::none_of(entry.mod.levels.begin(), entry.mod.levels.end(), [&](const std::string& level) { return same(level, key); })) continue;
result = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
break;
std::unique_lock lock(known_mutex);
// (Only with no table at all, just after a map was installed, is the Mods folder read here.)
if (!known_packages.valid) {
lock.unlock();
auto table = read_known_packages();
lock.lock();
if (!known_packages.valid) {
known_packages.by_level = std::move(table);
known_packages.read_at = now;
known_packages.valid = true;
}
}
cache[key] = {result, now + 120000};
return result;
refresh_known_packages(now);
const auto found = known_packages.by_level.find(key);
return found != known_packages.by_level.end() ? found->second : std::string{};
}
}
+8
View File
@@ -9,6 +9,11 @@
// player is asked; on yes the package is downloaded, installed into Mods and applied as a live
// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there;
// on no, or when the map cannot be had, the session is left.
// The session waits 30 minutes on its local monotonic clock for the whole of it (checking,
// confirmation, download, installation, applying and joining); confirmation gets two of them.
// A download that has begun is not cut short when the session stops waiting: it finishes,
// installs and is applied, and the player is told to join again. A cancelled worker drains
// before another attempt can begin.
//
// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore
// community, not deprecated and not rejected by its moderators; what is installed has to be a
@@ -55,4 +60,7 @@ void tick();
// The Thunderstore package an enabled mod's level comes from (its folder and manifest), for a
// host to tell its guests; empty when the level is the game's own or the mod is not a package.
std::string package_of(std::string_view asset);
// Reads the table package_of answers from, on a thread of its own, if it has not been (so the
// first map offer does not wait on the Mods folder). Cheap to call each frame; tick() does.
void warm_packages() noexcept;
}
+49
View File
@@ -9,7 +9,9 @@
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Session/session.h"
#include "Extension/UI/Overlay/overlay.h"
#include <algorithm>
#include <atomic>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <map>
@@ -136,9 +138,56 @@ void fetch(unsigned serial, std::vector<multiplayer::NextMapWanted> maps) {
}
fetching.store(false);
}
// The menu's map grid: each level's picture, a custom map's mod icon (its path found on the client
// thread) or an official map's loading screen.
std::atomic<bool> menu_fetching{};
void fetch_menu(std::vector<std::pair<overlay::MenuMapWanted, fs::path>> maps) {
std::vector<overlay::MapVoteIcon> icons;
for (auto& [map, icon_path] : maps) {
overlay::MapVoteIcon icon;
if (!icon_path.empty()) icon.png = read_bytes(icon_path);
if (icon.png.empty()) {
icon.rgba = game_picture(map.name);
if (!icon.rgba.empty()) icon.side = picture_side;
}
if (icon.png.empty() && icon.rgba.empty()) continue;
icon.name = std::move(map.asset);
icons.push_back(std::move(icon));
}
overlay::set_menu_map_pictures(icons);
try {
logging::log(logging::Level::info, logging::Channel::assets, "Menu map grid: {} of {} map(s) with a picture.", icons.size(), maps.size());
} catch (...) {}
menu_fetching.store(false);
}
bool same_asset(std::string_view a, std::string_view b) {
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
});
}
void menu_tick() {
if (menu_fetching.load()) return;
auto wanted = overlay::take_menu_map_request();
if (wanted.empty()) return;
std::vector<std::pair<overlay::MenuMapWanted, fs::path>> maps;
const auto& catalog = mods::catalog();
for (auto& map : wanted) {
fs::path icon;
for (const auto& mod : catalog.mods)
if (std::ranges::any_of(mod.levels, [&](const std::string& level) { return same_asset(level, map.asset); })) {
icon = mod.directory / L"icon.png";
break;
}
maps.emplace_back(std::move(map), std::move(icon));
}
menu_fetching.store(true);
std::thread(fetch_menu, std::move(maps)).detach();
}
} // namespace
void tick() {
menu_tick();
const auto serial = multiplayer::next_maps_serial();
if (serial == wanted_serial.load() || fetching.load()) return;
wanted_serial.store(serial);
+100 -54
View File
@@ -1,11 +1,14 @@
#include "native_render_resource_pool.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/render_resource_pool.h"
#include <Windows.h>
#include <intrin.h>
#include <array>
#include <atomic>
#include <cstddef>
@@ -13,6 +16,7 @@
#include <limits>
#include <mutex>
#include <span>
#include <utility>
namespace dingosdk {
namespace {
@@ -61,6 +65,10 @@ static_assert(relation_mask_bytes == 0x00300000);
using NativeConstructor = void* (__fastcall *)(void*, void*);
using IndexTableConstructor = void* (__fastcall *)(void*, void*, void*);
// Records and metadata every renderer built on the constructor uses; the
// resource states and relation masks only the state owner has
// (contract::state_owner_call_rva). 115.5 MB of states went to the other
// renderer as well, which never touches them.
struct Buffers {
std::byte* records{};
std::uint32_t* metadata{};
@@ -68,9 +76,8 @@ struct Buffers {
std::byte* resource_states{};
std::uint64_t* relation_masks{};
explicit operator bool() const noexcept {
return records && metadata && resource_flags && resource_states && relation_masks;
}
explicit operator bool() const noexcept { return records && metadata && resource_flags; }
bool has_states() const noexcept { return resource_states && relation_masks; }
};
struct IndexTableBuffers {
@@ -89,6 +96,10 @@ std::array<IndexTableBuffers, 2> reserved_index_tables{};
std::size_t next_reserved_index_table{};
std::byte* resource_flag_stubs{};
std::uintptr_t installed_base{};
// Where the state owner's constructor call returns to in the game.
std::atomic<std::uintptr_t> state_owner_return{};
// The renderer that has resource states, once constructed.
std::atomic<std::uintptr_t> state_owner{};
bool read_memory(const void* source, void* destination, std::size_t size) noexcept {
SIZE_T count{};
@@ -122,13 +133,22 @@ bool validate_contract(std::uintptr_t base) noexcept {
contract::resource_flags_read_priority) ||
!matches(base + contract::resource_flags_visibility_rva,
contract::resource_flags_read_visibility)) return false;
// The state owner's call to the constructor: an E8 call whose target is it.
std::array<std::uint8_t, 5> call{};
std::int32_t displacement{};
if (!read_memory(reinterpret_cast<const void*>(base + contract::state_owner_call_rva), call.data(), call.size()) ||
call[0] != 0xe8) return false;
std::memcpy(&displacement, call.data() + 1, sizeof(displacement));
if (contract::state_owner_call_rva + call.size() + displacement != contract::constructor_rva) return false;
for (const auto& patch : contract::patches)
if (patch.expected.size() != patch.replacement.size() ||
!matches(base + patch.rva, patch.expected)) return false;
return true;
}
Buffers allocate_buffers() noexcept {
void free_buffers(Buffers& buffers) noexcept;
Buffers allocate_buffers(bool states) noexcept {
Buffers result{};
result.records = static_cast<std::byte*>(VirtualAlloc(
nullptr, record_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
@@ -140,19 +160,13 @@ Buffers allocate_buffers() noexcept {
return {};
}
result.resource_flags = reinterpret_cast<std::uint8_t*>(result.metadata) + metadata_bytes;
if (!states) return result;
result.resource_states = static_cast<std::byte*>(VirtualAlloc(
nullptr, resource_state_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
if (!result.resource_states) {
VirtualFree(result.metadata, 0, MEM_RELEASE);
VirtualFree(result.records, 0, MEM_RELEASE);
return {};
}
result.relation_masks = static_cast<std::uint64_t*>(VirtualAlloc(
result.relation_masks = !result.resource_states ? nullptr : static_cast<std::uint64_t*>(VirtualAlloc(
nullptr, relation_mask_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
if (!result.relation_masks) {
VirtualFree(result.resource_states, 0, MEM_RELEASE);
VirtualFree(result.metadata, 0, MEM_RELEASE);
VirtualFree(result.records, 0, MEM_RELEASE);
if (!result.has_states()) {
free_buffers(result);
return {};
}
return result;
@@ -195,16 +209,34 @@ void free_reserved_allocations() noexcept {
next_reserved_index_table = 0;
}
Buffers take_buffers() noexcept {
Buffers take_buffers(bool states) noexcept {
std::lock_guard lock(allocation_mutex);
// Two renderers are built on the constructor: the first takes the reserved
// records and metadata, the state owner the reserved states, whichever
// comes first. Another instance costs an isolated allocation of its own
// and avoids aliasing records.
Buffers result{};
if (reserved_buffers) {
const auto result = reserved_buffers;
reserved_buffers = {};
result.records = std::exchange(reserved_buffers.records, nullptr);
result.metadata = std::exchange(reserved_buffers.metadata, nullptr);
result.resource_flags = std::exchange(reserved_buffers.resource_flags, nullptr);
} else {
result = allocate_buffers(false);
if (!result) return {};
}
if (!states) return result;
if (reserved_buffers.has_states()) {
result.resource_states = std::exchange(reserved_buffers.resource_states, nullptr);
result.relation_masks = std::exchange(reserved_buffers.relation_masks, nullptr);
return result;
}
// The renderer is normally a singleton. Supporting a subsequent native
// instance costs another isolated allocation and avoids aliasing records.
return allocate_buffers();
const auto fresh = allocate_buffers(true);
if (!fresh) { free_buffers(result); return {}; }
result.resource_states = fresh.resource_states;
result.relation_masks = fresh.relation_masks;
VirtualFree(fresh.metadata, 0, MEM_RELEASE);
VirtualFree(fresh.records, 0, MEM_RELEASE);
return result;
}
IndexTableBuffers take_index_table_buffers() noexcept {
@@ -259,9 +291,15 @@ void* __fastcall construct_index_table(void* self, void* first, void* second) {
void* __fastcall construct_native_renderer(void* self, void* configuration) {
const auto incoming_error = GetLastError();
const auto buffers = take_buffers();
// (The hook is jumped to, so this returns where the game's call does.)
const auto caller = reinterpret_cast<std::uintptr_t>(_ReturnAddress());
const bool states = caller == state_owner_return.load(std::memory_order_acquire);
const auto buffers = take_buffers(states);
const auto original = original_constructor.load(std::memory_order_acquire);
if (!self || !buffers || !original) allocation_failure();
if (!self || !buffers || (states && !buffers.has_states()) || !original) allocation_failure();
logging::log(logging::Level::info, logging::Channel::graphics,
"Render-resource pool: renderer built from Skate.exe+{:#x}, {}.", caller - installed_base,
states ? "with the enlarged resource states" : "without resource states (it has none)");
auto* const record_end = buffers.records + record_bytes;
auto* const metadata_end = buffers.metadata + native_render_resource_capacity;
@@ -270,10 +308,16 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) {
// The patched constructor loads its initial vector pointer from +0x90,
// constructs the native default record there, and advances +0x98 itself.
store_field(self, vector_begin_offset, buffers.records);
// Both renderers read the metadata through these pointers (render_resource_pool.h). The other
// renderer was allocated 0x2daa00 bytes, and they were written 18 MB past its end, into
// whatever lay there (a crash in store_field when nothing did): it is allocated the state
// owner's size now (the patch at 0x030c64cc), so they are its own.
store_field(self, metadata_begin_offset, buffers.metadata);
store_field(self, metadata_end_offset, metadata_end);
store_field(self, resource_state_vector_offset, buffers.resource_states);
store_field(self, relation_mask_vector_offset, relation_mask_begin);
if (states) {
store_field(self, resource_state_vector_offset, buffers.resource_states);
store_field(self, relation_mask_vector_offset, relation_mask_begin);
}
SetLastError(incoming_error);
auto* const result = original(self, configuration);
@@ -281,6 +325,7 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) {
// stock build. Keep the external values authoritative across construction.
store_field(self, metadata_begin_offset, buffers.metadata);
store_field(self, metadata_end_offset, metadata_end);
if (states) state_owner.store(reinterpret_cast<std::uintptr_t>(self), std::memory_order_release);
if (load_field<std::byte*>(self, vector_begin_offset) != buffers.records ||
load_field<std::byte*>(self, vector_capacity_offset) != record_end ||
load_field<std::byte*>(self, vector_end_offset) < buffers.records ||
@@ -314,34 +359,6 @@ bool relative_jump(std::uintptr_t instruction, std::uintptr_t destination,
return true;
}
std::byte* allocate_near_game_image(std::uintptr_t base) noexcept {
SYSTEM_INFO information{};
GetSystemInfo(&information);
const auto granularity = static_cast<std::uintptr_t>(information.dwAllocationGranularity);
const auto align_up = [granularity](std::uintptr_t value) {
return (value + granularity - 1) & ~(granularity - 1);
};
auto cursor = align_up(base + supported_build::game_image_size);
const auto limit = base + 0x70000000ULL;
while (cursor < limit) {
MEMORY_BASIC_INFORMATION region{};
if (!VirtualQuery(reinterpret_cast<const void*>(cursor), &region, sizeof(region))) break;
const auto region_base = reinterpret_cast<std::uintptr_t>(region.BaseAddress);
const auto region_end = region_base + region.RegionSize;
if (region.State == MEM_FREE) {
const auto candidate = align_up(cursor > region_base ? cursor : region_base);
if (candidate <= region_end && region_end - candidate >= resource_flag_stub_page_bytes) {
if (auto* const result = static_cast<std::byte*>(VirtualAlloc(
reinterpret_cast<void*>(candidate), resource_flag_stub_page_bytes,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE))) return result;
}
}
if (region_end <= cursor) break;
cursor = align_up(region_end);
}
return nullptr;
}
template<std::size_t N>
bool write_stub(std::byte* destination, std::span<const std::uint8_t, N> prefix,
std::uintptr_t return_address) noexcept {
@@ -357,7 +374,8 @@ bool write_stub(std::byte* destination, std::span<const std::uint8_t, N> prefix,
}
bool prepare_resource_flag_stubs(std::uintptr_t base) noexcept {
resource_flag_stubs = allocate_near_game_image(base);
resource_flag_stubs = static_cast<std::byte*>(
memory::allocate_near_image(base, supported_build::game_image_size, resource_flag_stub_page_bytes));
if (!resource_flag_stubs) return false;
std::memset(resource_flag_stubs, 0xcc, resource_flag_stub_page_bytes);
if (!write_stub(resource_flag_stubs + priority_stub_offset,
@@ -431,6 +449,33 @@ void restore_patches(std::uintptr_t base, std::size_t count) noexcept {
}
} // namespace
void report_native_render_resources() noexcept {
const auto owner = state_owner.load(std::memory_order_acquire);
std::uintptr_t begin{}, end{};
if (!owner || !memory::peek(owner + vector_begin_offset, begin) || !memory::peek(owner + vector_end_offset, end) ||
end < begin || end > begin + record_bytes)
return;
const auto used = (end - begin) / native_render_resource_record_size;
static std::size_t said{};
static bool warned{}, full{};
// (A new high, in steps of 1/32: how close a level came is worth having in a log.)
if (used < said + native_render_resource_capacity / 32 && !(used >= native_render_resource_capacity && !full)) return;
said = used;
const auto percent = used * 100 / native_render_resource_capacity;
if (used >= native_render_resource_capacity) {
full = true;
logging::log(logging::Level::error, logging::Channel::graphics,
"Render-resource pool: all {} records are taken; the level needs more than the game can address.", used);
} else if (percent >= 90 && !warned) {
warned = true;
logging::log(logging::Level::warning, logging::Channel::graphics, "Render-resource pool: {} of {} records taken ({}%).", used,
native_render_resource_capacity, percent);
} else {
logging::log(logging::Level::info, logging::Channel::graphics, "Render-resource pool: {} of {} records taken ({}%).", used,
native_render_resource_capacity, percent);
}
}
bool start_native_render_resource_pool(std::uintptr_t base, std::string& error) {
std::lock_guard lock(install_mutex);
error.clear();
@@ -446,7 +491,7 @@ bool start_native_render_resource_pool(std::uintptr_t base, std::string& error)
{
std::lock_guard allocation_lock(allocation_mutex);
reserved_buffers = allocate_buffers();
reserved_buffers = allocate_buffers(true);
for (auto& table : reserved_index_tables) table = allocate_index_table_buffers();
next_reserved_index_table = 0;
if (!reserved_buffers || !reserved_index_tables[0] || !reserved_index_tables[1]) {
@@ -520,6 +565,7 @@ bool start_native_render_resource_pool(std::uintptr_t base, std::string& error)
return false;
}
state_owner_return.store(base + contract::state_owner_call_rva + 5, std::memory_order_release);
original_constructor.store(original, std::memory_order_release);
original_index_table_constructor.store(original_index, std::memory_order_release);
const auto queued_index = hook_queue_enable(index_target);
@@ -14,4 +14,9 @@ inline constexpr std::size_t native_render_resource_record_size = 0xf0;
// constructing its records normally, but its backing storage and all parallel
// per-resource arrays are redirected to process-lifetime allocations.
bool start_native_render_resource_pool(std::uintptr_t base, std::string& error);
// How many of the pool's records the renderer that has resource states has taken: a level that
// needs more than native_render_resource_capacity cannot be drawn. Said in the log as it grows,
// checked every frame so the last count before a crash is there. Game thread.
void report_native_render_resources() noexcept;
} // namespace dingosdk
@@ -1,6 +1,7 @@
// Board Break's rules, apart from the game: what a break leaves of a board, how likely a slam
// breaks it, and where the parts of a broken board lie.
#include "Extension/BoardBreak/board_break_model.h"
#include "Extension/BoardBreak/board_break_feel.h"
#include <cmath>
#include <initializer_list>
#include <iostream>
@@ -35,12 +36,12 @@ void a_break_leaves_two_pieces_or_one_that_hangs_together() {
}
void a_harder_slam_breaks_a_board_more_often() {
check(break_chance(10.0f, 10.0f, 0.25f) == 1.0f && break_chance(30.0f, 10.0f, 0.25f) == 1.0f, "A heavy slam always breaks it");
check(break_chance(0.0f, 10.0f, 0.25f) == 0.25f && break_chance(6.0f, 10.0f, 0.25f) == 0.25f, "A light one now and then");
check(near(break_chance(8.0f, 10.0f, 0.25f), 0.625f), "and in between it rises evenly");
check(break_chance(10.0f, 10.0f) == 1.0f && break_chance(30.0f, 10.0f) == 1.0f, "A heavy slam always breaks it");
check(break_chance(0.0f, 10.0f) == 0.0f && break_chance(6.0f, 10.0f) == 0.0f, "A light one never does");
check(near(break_chance(8.0f, 10.0f), 0.5f), "and in between it rises smoothly");
float before{};
for (float force = 0; force <= 12.0f; force += 0.5f) {
const auto chance = break_chance(force, 10.0f, 0.25f);
const auto chance = break_chance(force, 10.0f);
check(chance >= before && chance <= 1.0f, "It never falls with more force");
before = chance;
}
@@ -148,6 +149,66 @@ void a_baseplate_stays_on_the_deck_while_its_truck_steers() {
}
}
}
void the_settings_keep_to_their_ranges() {
Settings s;
check(set_setting_value(s, "sensitivity", 9999) && s.sensitivity == sensitivity_max, "A percent is kept within its range");
check(set_setting_value(s, "always", 5) && s.always && setting_value(s, "always") == 1, "a switch is on or off");
check(set_setting_value(s, "joined", -3) && s.joined == 0, "and nothing goes below its least");
check(!set_setting_value(s, "nonsense", 1) && setting_value(s, "nonsense") == -1, "An unknown name is no setting");
for (const auto& info : setting_info) check(setting_value(s, info.name) >= 0, "Every setting has a value");
}
void a_worn_or_touchy_board_breaks_sooner() {
check(slam_chance(heavy_slam, 100, 0.0f) == 1.0f, "A heavy slam always breaks a new board");
check(slam_chance(6.0f, 100, 0.0f) < slam_chance(6.0f, 100, 1.0f), "A worn board breaks from less");
check(slam_chance(heavy_slam * 0.5f, 200, 0.0f) == 1.0f, "At 200% half the force always breaks it");
check(slam_chance(8.0f, 50, 0.0f) < slam_chance(8.0f, 100, 0.0f), "at 50% it takes more");
check(slam_chance(2.0f, 100, 0.0f) == 0.0f && slam_chance(2.0f, 100, 0.6f) == 0.0f, "A stumble never breaks a board that is not done");
check(slam_chance(0.0f, 400, 1.0f) == 0.0f, "nor does a bail whose force is not known, at any setting");
}
void every_hit_wears_a_board() {
check(landing_wear(3.0f) == 0.0f && landing_wear(5.0f) > 0.0f && landing_wear(9.0f) > landing_wear(5.0f), "Harder landings wear more, light ones none");
check(rail_wear(3.0f, true) > rail_wear(3.0f, false), "A slide wears the deck more than a grind on the trucks");
check(grind_wear_per_second(true) > grind_wear_per_second(false), "and sliding more than grinding");
check(slam_wear(2.0f) == 0.0f && slam_wear(12.0f) > slam_wear(7.0f), "A stumble costs nothing, a hard slam a lot");
check(add_wear(0.5f, 0.1f, 0) == 0.5f && near(add_wear(0.5f, 0.1f, 200), 0.7f) && add_wear(0.95f, 1.0f, 100) == 1.0f,
"Wear goes at the player's rate, and never past worn out");
}
void a_worn_board_handles_worse() {
check(handling(0.0f) == Handling{} && handling(0.2f) == Handling{}, "A board under a fifth worn handles as new");
const auto half = handling(0.6f), done = handling(1.0f);
check(half.steering < 1.0f && done.steering < half.steering && done.spring < half.spring && done.twist > half.twist,
"The more worn, the lazier the turn and the softer the trucks");
check(done.steering > 0.5f && done.spring > 0.2f, "but it can still be ridden");
check(!gives_way_on_landing(20.0f, 400, done_wear - 0.01f), "A board that is not done never gives way on a landing");
check(!gives_way_on_landing(6.0f, 100, done_wear) && gives_way_on_landing(6.5f, 100, done_wear),
"A board just done goes on a big drop");
check(!gives_way_on_landing(3.0f, 100, 1.0f) && gives_way_on_landing(3.5f, 100, 1.0f),
"one worn right out on any real drop, but not on an ollie on flat ground");
check(gives_way_on_landing(4.0f, 200, done_wear) && !gives_way_on_landing(4.0f, 100, done_wear), "Sensitivity makes it touchier");
}
void a_board_across_its_way_slides() {
const Vec level_up{0, 1, 0}, ahead{0, 0, 1}, across{1, 0, 0}, upside_down{0, -1, 0}, going{0, -1, 5};
check(contact_of(ahead, level_up, going) == Contact::grind, "Along the rail it grinds on its trucks");
check(contact_of(across, level_up, going) == Contact::slide, "Across it, it slides on the deck");
check(contact_of(across, upside_down, going) == Contact::darkslide, "On its grip it is a darkslide");
check(contact_of(across, level_up, {0, -2, 0.1f}) == Contact::grind, "A board that hardly moves along counts as grinding");
check(rail_chance(9.0f, Contact::grind, 100, 0.0f) == 0.0f, "A grind on the trucks never snaps the board");
check(rail_chance(2.0f, Contact::slide, 100, 0.0f) == 0.0f && rail_chance(6.0f, Contact::slide, 100, 0.0f) == 1.0f,
"A hop onto a rail is safe, a drop onto it snaps the board");
check(rail_chance(3.5f, Contact::darkslide, 100, 0.0f) > rail_chance(3.5f, Contact::slide, 100, 0.0f), "a darkslide sooner");
}
void a_board_breaks_where_it_hit() {
check(place_of_break(0.8f, false, 0.1f) == Place::nose && place_of_break(-0.8f, false, 0.1f) == Place::tail, "Nose first at the nose, tail first at the tail");
check(place_of_break(0.8f, true, 0.1f) == Place::mid, "Across a rail in the middle");
check(place_of_break(0.0f, false, 0.2f) == Place::mid && place_of_break(0.0f, false, 0.9f) == Place::tail, "Flat mostly in the middle");
check(body_loudness(2.0f) == 0.0f && body_loudness(body_full) == 1.0f && body_loudness(5.0f) < body_loudness(9.0f), "Harder hits sound louder");
}
} // namespace
int main() {
@@ -162,6 +223,12 @@ int main() {
a_hanging_end_turns_about_the_break();
a_truck_goes_with_its_piece();
a_baseplate_stays_on_the_deck_while_its_truck_steers();
the_settings_keep_to_their_ranges();
a_worn_or_touchy_board_breaks_sooner();
every_hit_wears_a_board();
a_worn_board_handles_worse();
a_board_across_its_way_slides();
a_board_breaks_where_it_hit();
std::cout << "board break tests passed\n";
return 0;
} catch (const std::exception& error) {
+695 -48
View File
@@ -9,34 +9,51 @@
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Skater/no_bail.h"
#include "board_break_audio.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/skater_state.h"
#include "Extension/HallOfMeat/hall_of_meat_model.h"
#include "Extension/HallOfMeat/hall_of_meat_skater.h"
#include "Extension/Multiplayer/Remote/native_vfx.h"
#include "Extension/Multiplayer/Session/session.h"
#include "Extension/Progression/script_natives.h"
#include "Extension/Skater/physics_tuning.h"
#include "Extension/UI/Overlay/overlay.h"
#include <Windows.h>
#include <mmsystem.h>
#include <shlobj.h>
#include <algorithm>
#include <thread>
#include <sstream>
#include <array>
#include <atomic>
#include <cctype>
#include <charconv>
#include <cmath>
#include <cstring>
#include <cwctype>
#include <filesystem>
#include <format>
#include <fstream>
#include <limits>
#include <map>
#include <mutex>
#include <optional>
#include <random>
#include <set>
#include <string>
#include <vector>
#pragma comment(lib, "winmm.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "shell32.lib")
namespace dingosdk::board_break {
namespace {
constexpr const char* enabled_preference = "BoardBreak";
// A slam of `heavy` force or more always breaks the board; a lighter one with `light`, rising to
// certain between 60% of `heavy` and `heavy` (break_chance). Half of all breaks stay joined by the
// grip tape. Now and then a board gives way under the rider: per second of riding, the nose goes.
constexpr float heavy = 10.0f, light = 0.25f, joined_share = 0.5f;
constexpr double gives_way = 0.0001;
// A slam of `heavy` force or more always breaks the board, one under 60% of it never, and the
// chance rises smoothly in between (break_chance). Half of all breaks stay joined by the grip tape.
// A board that is done gives way under the rider on a hard enough landing: the nose goes.
// (board_break_feel.h holds the forces, the chances and what wear does.)
namespace sstate = addr::skater_state;
constexpr std::size_t cause_count = 35; // the collector's cause bytes (+0x28) and forces (+0x4c)
constexpr std::uint64_t recent_ms = 1500; // a cause belongs to a wipeout this soon before it
// The board is back in the skater's hand: close to the skater and not lying flat.
@@ -50,6 +67,8 @@ constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4,
constexpr int back_plate_bone = 5, front_plate_bone = 9;
constexpr std::array<int, 4> carrier{12, 13, 15, 14};
constexpr std::uint32_t rig_bones = 16;
// The wheels, each on its truck's bone: back right and left, front right and left.
constexpr std::array<int, 4> wheel_bones{6, 7, 10, 11};
bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; }
@@ -71,6 +90,7 @@ struct Broken {
std::uint64_t since{};
bool gave_way{}; // it went under the rider: it stays broken while it is ridden
bool asked{}; // it was broken from the console, to be looked at: it stays until it is mended there
std::string deck; // the deck it was: the one that gets a new board
};
struct State {
// Any thread.
@@ -86,13 +106,32 @@ struct State {
float angle{}; // how far a hanging end droops (radians), or how deep a fold sags (0 to 1)
std::uint32_t flights{}; // counts the breaks, so each starts a flight of its own
bool counted{}, hooked{};
std::uint64_t wipeouts{}, back_since{}, last_error{}, rode_at{}, board_seen{};
std::uint64_t wipeouts{}, back_since{}, last_error{}, board_seen{};
// The ground under the board: its own height while it rolls on it, and what of that was fresh
// when the board broke (else not a number).
float ride_height{};
std::uint64_t ride_when{};
float break_ground{std::numeric_limits<float>::quiet_NaN()};
std::mt19937 random{std::random_device{}()};
// The player's settings as the physics thread reads them (feel() holds the real ones).
std::atomic<bool> always{}, rails{true}, wear_on{true}, body_sounds{true};
std::atomic<int> sensitivity{100};
// Wear: the board in the game's (for the physics thread), and what the physics thread has
// added since the tick took it.
std::atomic<float> wear_now{}, wear_added{};
// A board that came down too hard onto a rail, seen by the physics thread: the tick breaks it.
std::atomic<bool> rail_snap{};
std::atomic<bool> landing_give{}; // the physics thread saw a done board land too hard
std::atomic<float> landing_speed{};
std::atomic<int> rail_contact{};
std::atomic<float> rail_speed{};
// The board's long axis (towards the nose) and its up axis in the world, from the tick.
std::mutex axes_mutex;
Vec along{}, up{};
std::uint64_t axes_at{};
// Client thread: the tuning set for wear, and when; when the settings were last saved.
std::uint64_t handled_at{}, saved_at{}, session_checked{};
bool in_session{}, setup_read{}, effects_hooked{};
};
State& state() {
static auto* value = new State;
@@ -106,6 +145,8 @@ struct RenderEdit {
std::atomic<int> hinge{};
std::atomic<float> angle{};
std::atomic<bool> park{}; // a breakable deck is in the game: whole boards hide their stretches
// The deck's own trucks (deck.ini: axle): how far apart the wheels sit against a standard board's.
std::atomic<float> axle{1.0f};
};
RenderEdit& render_edit() { static RenderEdit value; return value; }
@@ -170,6 +211,28 @@ void hold_baseplates(std::uintptr_t buffer) noexcept {
hold(front_truck_bone, front_plate_bone, front_truck_rest);
}
// A deck's own trucks (deck.ini: axle): the wheels further apart on a wider deck, closer together on
// a narrower one. The game puts each wheel on its truck's bone along the axle, about 0.0876 m out;
// that place is set here rather than scaled, so a pose that comes by twice is not moved twice. Only
// what is drawn changes: the board rides as it always does.
constexpr float wheel_offset = 0.0876f;
void spread_wheels(std::uintptr_t buffer) noexcept {
const float axle = render_edit().axle.load(std::memory_order_relaxed);
if (axle == 1.0f) return;
for (const int index : wheel_bones) {
PoseBone wheel{};
if (!load_bone(bone_at(buffer, index), wheel)) continue;
int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits
for (int i = 1; i < 3; ++i)
if (std::abs(wheel.position[i]) > std::abs(wheel.position[way])) way = i;
const float out = std::abs(wheel.position[way]);
if (!(out > 0.05f && out < 0.16f)) continue; // not where the game puts a wheel
__try {
reinterpret_cast<float*>(bone_at(buffer, index))[8 + way] = std::copysign(wheel_offset * axle, wheel.position[way]);
} __except (EXCEPTION_EXECUTE_HANDLER) {}
}
}
// ---- the piece that comes off --------------------------------------------------------------------
// The client tick starts a flight (a new id) when the board breaks; the render pose hook moves the
// piece through the world from there (a kick, gravity, the ground) and writes it relative to
@@ -303,6 +366,7 @@ void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept,
!load_bone(bone_at(buffer, deck_bone), deck))
return;
hold_baseplates(buffer);
spread_wheels(buffer);
// From where its animation put it this frame; a pose nobody has written since the last move stays as it is.
if (const Vec at = place_of(deck); length(at) < 100.0f) (void)store_bone(bone_at(buffer, deck_bone), facing_of(deck), at + away);
auto layout = lay_out(kept, hinge, angle, turn(facing_of(root), {0, 1, 0}).y, facing_of(mount));
@@ -323,71 +387,508 @@ void park_pieces(std::uintptr_t base, std::uintptr_t holder) {
const auto buffer = board_pose(base, holder);
if (!buffer) return;
hold_baseplates(buffer);
spread_wheels(buffer);
const auto bones = bones_of({});
for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at);
}
// ---- breaking and mending --------------------------------------------------------------------------
// The crack of the deck, straight to the speakers (not through the game's mixer): a wave file out
// of the `sounds` folder of one of Board Break's deck mods. Its subfolders light, medium and heavy
// hold the sounds for a break of that size, as many as one likes under any names; a size with none
// of its own takes the nearest size that has some, and wave files in the folder itself serve when
// no subfolder does.
void crack_sound(State& s, Size size) {
static const std::array<std::vector<std::filesystem::path>, 4> sounds = [] {
std::array<std::vector<std::filesystem::path>, 4> found;
constexpr std::array<const wchar_t*, 4> names{L"light", L"medium", L"heavy", L""};
// ---- the player's settings, the deck's setup, wear -------------------------------------------------
// Kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings, a line each: the settings by name (as the
// console knows them), each deck's wear (wear.<deck>=0.37), the materials marked as metal.
struct Feel {
std::mutex mutex;
Settings settings;
std::map<std::string, float> wear; // by deck
std::set<std::uint16_t> metal; // marked by the player
std::set<std::uint16_t> pack_metal; // a pack's list (sounds/metal/materials.txt)
bool loaded{}, dirty{};
};
Feel& feel() {
static auto* value = new Feel;
return *value;
}
std::filesystem::path settings_path() {
PWSTR raw{};
std::filesystem::path result;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw)
result = std::filesystem::path(raw) / L"ReSkate" / L"BoardBreak.settings";
if (raw) CoTaskMemFree(raw);
return result;
}
std::string trimmed(std::string_view text) {
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1);
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) text.remove_suffix(1);
return std::string(text);
}
template <class T> bool number(std::string_view text, T& out) {
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), out);
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
}
// Numbers out of a line of them ("12 40, 77").
std::set<std::uint16_t> numbers_in(std::string_view text) {
std::set<std::uint16_t> out;
std::string one;
const auto take = [&] {
unsigned value{};
if (!one.empty() && number(std::string_view(one), value) && value <= 0xffff) out.insert(static_cast<std::uint16_t>(value));
one.clear();
};
for (const char c : text) {
if (c >= '0' && c <= '9') one += c;
else take();
}
take();
return out;
}
// Under the lock.
void load_feel(Feel& f) {
if (f.loaded) return;
f.loaded = true;
try {
std::ifstream in(settings_path());
std::string line;
while (std::getline(in, line)) {
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
const auto key = trimmed(std::string_view(line).substr(0, eq));
const auto value = trimmed(std::string_view(line).substr(eq + 1));
if (key.starts_with("wear.")) {
float worn{};
if (number(std::string_view(value), worn) && std::isfinite(worn)) f.wear[key.substr(5)] = std::clamp(worn, 0.0f, 1.0f);
} else if (key == "metal") f.metal = numbers_in(value);
else if (int setting{}; number(std::string_view(value), setting)) (void)set_setting_value(f.settings, key, setting);
}
} catch (...) {}
}
// Written on a thread of its own: this is called from the client tick every few seconds while
// wear changes, and a write (a folder lookup, a file truncated and written, whatever the virus
// scanner does on close) held the game's frame up (2026-10-15). The text is taken under the lock;
// the writes are kept in order by their own.
void save_feel(Feel& f) {
try {
std::ostringstream out;
for (const auto& info : setting_info) out << info.name << "=" << setting_value(f.settings, info.name) << "\n";
for (const auto& [deck, worn] : f.wear) out << "wear." << deck << "=" << std::format("{:.4f}", worn) << "\n";
if (!f.metal.empty()) {
out << "metal=";
for (const auto m : f.metal) out << m << " ";
out << "\n";
}
f.dirty = false;
std::thread([text = out.str()] {
static std::mutex writing;
std::lock_guard lock(writing);
try {
const auto path = settings_path();
if (path.empty()) return;
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
std::ofstream file(path, std::ios::trunc);
file << text;
} catch (...) {}
}).detach();
} catch (...) {}
}
// The settings into the atomics the physics thread reads.
void mirror(State& s, const Settings& st) {
s.always.store(st.always, std::memory_order_relaxed);
s.rails.store(st.rails, std::memory_order_relaxed);
s.wear_on.store(st.wear, std::memory_order_relaxed);
s.body_sounds.store(st.body_sounds, std::memory_order_relaxed);
s.sensitivity.store(st.sensitivity, std::memory_order_relaxed);
}
Settings settings_now() {
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
return f.settings;
}
std::string deck_now(State& s) {
std::lock_guard lock(s.names);
return s.deck;
}
// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's;
// more on a wider deck) and `label`, its name in the menus.
struct DeckSetup {
float axle = 1.0f;
std::string label;
};
DeckSetup read_deck_setup(std::string_view deck) {
DeckSetup out;
if (deck.empty()) return out;
try {
const auto folder = mods::board_break_deck_folder(deck);
if (folder.empty()) return out;
std::ifstream in(folder / L"deck.ini");
std::string line;
while (std::getline(in, line)) {
if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash);
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto key = trimmed(std::string_view(line).substr(0, eq));
for (auto& c : key) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const auto value = trimmed(std::string_view(line).substr(eq + 1));
float amount{};
if (key == "axle" && number(std::string_view(value), amount) && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f);
else if (key == "label") out.label = value;
}
} catch (...) {}
return out;
}
// The same, kept a minute per deck: the menus ask every frame.
DeckSetup setup_of(std::string_view deck) {
struct Kept {
DeckSetup setup;
std::uint64_t at{};
};
static std::mutex mutex;
static std::map<std::string, Kept, std::less<>> kept;
const auto now = GetTickCount64();
{
std::lock_guard lock(mutex);
// (A minute: each look lists every mod's folder, and the world model asked every few seconds
// on the client thread while a breakable deck was out.)
if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 60000) return at->second.setup;
}
auto setup = read_deck_setup(deck);
std::lock_guard lock(mutex);
kept[std::string(deck)] = {setup, now};
return setup;
}
// ---- sounds ---------------------------------------------------------------------------------------
// Out of the `sounds` folder of one of Board Break's deck mods: light, medium and heavy for the
// crack of a break that size, metal for a board breaking on metal, body for the body's hits in a
// bail; as many wave files in each as one likes, under any names. A crack size with none of its own
// takes the nearest that has some, and wave files in the folder itself serve when no subfolder does.
// sounds/metal/materials.txt may list the level's materials that are metal.
void load_sounds() {
static std::once_flag once;
std::call_once(once, [] {
const auto wavs = [](const std::filesystem::path& folder) {
std::vector<std::filesystem::path> found;
std::error_code failed;
for (std::filesystem::directory_iterator at(folder, failed), end; !failed && at != end; at.increment(failed)) {
auto extension = at->path().extension().wstring();
for (auto& c : extension) c = static_cast<wchar_t>(towlower(c));
if (at->is_regular_file(failed) && extension == L".wav") found.push_back(at->path());
}
std::sort(found.begin(), found.end());
return found;
};
for (const auto& mod : mods::board_break_folders()) {
std::error_code failed;
if (!std::filesystem::is_directory(mod / L"sounds", failed)) continue;
for (std::size_t i = 0; i < names.size(); ++i) {
for (std::filesystem::directory_iterator at(mod / L"sounds" / names[i], failed), end; !failed && at != end; at.increment(failed)) {
auto extension = at->path().extension().wstring();
for (auto& c : extension) c = static_cast<wchar_t>(towlower(c));
if (at->is_regular_file(failed) && extension == L".wav") found[i].push_back(at->path());
}
std::sort(found[i].begin(), found[i].end());
const auto folder = mod / L"sounds";
if (!std::filesystem::is_directory(folder, failed)) continue;
const auto loose = wavs(folder);
constexpr std::array<std::pair<audio::Kind, const wchar_t*>, 5> kinds{{{audio::Kind::light, L"light"}, {audio::Kind::medium, L"medium"},
{audio::Kind::heavy, L"heavy"}, {audio::Kind::metal, L"metal"}, {audio::Kind::body, L"body"}}};
for (const auto& [kind, name] : kinds) {
auto files = wavs(folder / name);
if (files.empty() && kind <= audio::Kind::heavy) files = loose;
audio::load(kind, files);
}
try {
std::ifstream in(folder / L"metal" / L"materials.txt");
std::string line, all;
while (std::getline(in, line))
if (const auto hash = line.find('#'); hash != 0) all += " " + line.substr(0, hash);
auto& f = feel();
std::lock_guard lock(f.mutex);
f.pack_metal = numbers_in(all);
} catch (...) {}
break;
}
return found;
}();
});
}
void crack_sound(Size size) {
if (!settings_now().crack_sounds) return;
// Nearest first: a medium break without sounds of its own takes a heavy one before a light one.
static constexpr std::array<std::array<std::size_t, 4>, 3> order{{{0, 1, 2, 3}, {1, 2, 0, 3}, {2, 1, 0, 3}}};
for (const auto from : order[static_cast<std::size_t>(size)]) {
const auto& files = sounds[from];
if (files.empty()) continue;
const auto& file = files[std::uniform_int_distribution<std::size_t>(0, files.size() - 1)(s.random)];
(void)PlaySoundW(file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT);
return;
static constexpr std::array<std::array<audio::Kind, 3>, 3> order{{{audio::Kind::light, audio::Kind::medium, audio::Kind::heavy},
{audio::Kind::medium, audio::Kind::heavy, audio::Kind::light},
{audio::Kind::heavy, audio::Kind::medium, audio::Kind::light}}};
for (const auto kind : order[static_cast<std::size_t>(size)])
if (audio::has(kind)) return audio::play(kind, 1.0f);
}
// ---- what a board broke on --------------------------------------------------------------------------
// The material of the level the board touched most in the last half second, near `at`: the game's
// own contact effects say (Extension/Multiplayer/Remote/native_vfx.h).
std::optional<std::uint16_t> material_near(Vec at, std::uint64_t within_ms, float reach) {
std::map<std::uint16_t, int> count;
for (const auto& one : multiplayer::recent_local_impacts(within_ms)) {
const auto& p = one.impact.position;
if (std::hypot(p[0] - at.x, p[1] - at.y, p[2] - at.z) <= reach) ++count[one.impact.material];
}
std::optional<std::uint16_t> best;
int most{};
for (const auto& [material, times] : count)
if (times > most) best = material, most = times;
return best;
}
bool is_metal(std::uint16_t material) {
auto& f = feel();
std::lock_guard lock(f.mutex);
return f.metal.contains(material) || f.pack_metal.contains(material);
}
// ---- wear ----------------------------------------------------------------------------------------
// Client thread: what the physics thread added goes onto the deck in the game's board.
void take_wear(State& s, const std::string& deck, float extra = 0.0f) {
const float added = s.wear_added.exchange(0.0f, std::memory_order_acq_rel) + extra;
if (deck.empty() || added <= 0.0f) return;
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
auto& worn = f.wear[deck];
const auto before = worn;
worn = add_wear(worn, added, f.settings.wear_rate);
s.wear_now.store(worn, std::memory_order_relaxed);
if (worn - before >= 0.01f) say(logging::Level::debug, "a hard hit: the board is {:.1f}% worn now (+{:.1f}).", worn * 100.0f, (worn - before) * 100.0f);
if (worn != before) f.dirty = true;
if (condition_name(before) != condition_name(worn)) {
say(logging::Level::info, "the {} board is {} now ({:.0f}% worn).", deck_label(deck), condition_name(worn), worn * 100.0f);
// Told as it gets close, so a break that follows is no surprise.
if (condition_name(worn) == "beat")
overlay::notify(overlay::NoticeLevel::info, "Your board is beat",
"It breaks from smaller slams now. New board: ReSkate menu (Insert) > Addons > Board Break.");
else if (condition_name(worn) == "done")
overlay::notify(overlay::NoticeLevel::warning, "Your board is done",
"The next big drop snaps it. New board: ReSkate menu (Insert) > Addons > Board Break.");
}
}
// A board that broke is a new one once it is whole again: no wear, no scratches.
void new_board(State& s, const std::string& deck) {
if (deck.empty()) return;
auto& f = feel();
{
std::lock_guard lock(f.mutex);
load_feel(f);
f.wear[deck] = 0.0f;
f.dirty = true;
}
if (deck == deck_now(s)) {
s.wear_now.store(0.0f, std::memory_order_relaxed);
s.wear_added.store(0.0f, std::memory_order_relaxed);
profile_runtime::reset_board_wear();
}
}
void add_physics_wear(State& s, float amount) {
if (!(amount > 0.0f)) return;
float current = s.wear_added.load(std::memory_order_relaxed);
while (!s.wear_added.compare_exchange_weak(current, current + amount, std::memory_order_acq_rel)) {}
}
// What wear does to the board's handling: four values of the game's physics tuning, the game's own
// value times handling()'s factor. A value the player changed (the trainer) is left alone; outside
// a multiplayer session only, which shares tuning between its players.
struct Tuned {
bool tried{}, known{}, wrote{};
std::array<std::uint16_t, 4> offset{};
std::array<float, 4> game{}, written{};
};
Tuned& tuned() {
static Tuned value;
return value;
}
void apply_handling(std::uintptr_t base, std::uintptr_t entity, const Handling& want) {
auto& t = tuned();
if (!t.known) {
physics_tuning::prepare();
const auto model = physics_tuning::game_tuning();
if (!model) return;
constexpr std::array<std::string_view, 4> names{"PhysicsSteering.GeneralScalar", "PhysicsTrucks.StaticTruckSpringStrength",
"PhysicsTrucks.StaticTruckDamping", "PhysicsTrucks.TruckTwistLimit"};
std::size_t found{};
for (std::size_t i = 0; i < model->fields.size() && i < model->field_names.size(); ++i)
for (std::size_t k = 0; k < names.size(); ++k)
if (model->field_names[i] == names[k] && model->fields[i].real && model->fields[i].offset + 4u <= model->image.size()) {
t.offset[k] = model->fields[i].offset;
std::memcpy(&t.game[k], model->image.data() + t.offset[k], 4);
++found;
}
if (!t.tried) {
t.tried = true;
if (found != names.size()) say(logging::Level::warning, "wear cannot change the handling: {} of the 4 tuning values found.", found);
}
if (found != names.size()) return;
t.known = true;
}
physics_tuning::Values live;
if (!physics_tuning::read_live(base, live)) return;
const std::array<float, 4> factor{want.steering, want.spring, want.damping, want.twist};
bool change{};
for (std::size_t k = 0; k < factor.size(); ++k) {
if (t.offset[k] + 4u > live.image.size()) return;
float now{};
std::memcpy(&now, live.image.data() + t.offset[k], 4);
const auto same = [](float a, float b) { return std::abs(a - b) <= 1e-4f * (std::max)(1.0f, std::abs(b)); };
if (!same(now, t.game[k]) && !(t.wrote && same(now, t.written[k]))) continue;
const float target = t.game[k] * factor[k];
t.written[k] = target;
if (!same(now, target)) {
std::memcpy(live.image.data() + t.offset[k], &target, 4);
change = true;
}
}
t.wrote = true;
if (change) (void)physics_tuning::write_live(base, entity, live);
}
} // namespace
std::optional<float> handling_value(std::uint16_t offset) noexcept {
const auto& t = tuned();
if (!t.known || !t.wrote) return {};
for (std::size_t k = 0; k < t.offset.size(); ++k)
if (t.offset[k] == offset) return t.written[k];
return {};
}
namespace {
// ---- the board's axes, for the physics thread -------------------------------------------------------
void note_axes(State& s, std::uintptr_t base, std::uintptr_t holder) {
const auto buffer = board_pose(base, holder);
PoseBone root{}, mount{};
if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return;
const auto facing = unit(facing_of(root) * facing_of(mount));
std::lock_guard lock(s.axes_mutex);
s.along = turn(facing, {0, 0, 1});
s.up = turn(facing, {0, 1, 0});
s.axes_at = GetTickCount64();
}
bool board_axes(State& s, Vec& along, Vec& up) {
std::lock_guard lock(s.axes_mutex);
if (!s.axes_at || GetTickCount64() - s.axes_at > 250) return false;
along = s.along;
up = s.up;
return true;
}
// ---- every physics step of the local skater (No Bail's step listeners) ------------------------------
// The board's root body velocity (as the grind exit control reads it).
bool board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity) noexcept {
std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{};
std::uint32_t count{};
if (!memory::peek(skater.core + sstate::board_holder_offset, holder) ||
!memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) ||
type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) ||
!memory::peek(bodies, count) || count != sstate::board_body_count)
return false;
const auto body = bodies + sstate::board_root_body * sstate::physics_body_size;
if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity))
return false;
return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; });
}
struct StepWatch {
bool was_air{};
float air_down{}; // how fast the board was coming down, in its last step in the air
std::array<std::uint64_t, skater_body::count> body_at{};
std::uint64_t any_at{};
};
void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
auto& s = state();
if (!s.ready.load(std::memory_order_acquire) || !s.on.load(std::memory_order_relaxed)) return;
static StepWatch w; // the physics thread's own
thread_local std::mt19937 roll{std::random_device{}()};
const auto error = GetLastError();
std::uint32_t physics{};
if (memory::peek(skater.context + sstate::physics_state_offset, physics)) {
std::array<float, 3> v{};
const bool known = board_velocity(skater, v);
const bool air = physics == 200 || physics == 201;
const bool grinding = physics >= 400 && physics < 500;
const bool rolling = physics >= 100 && physics < 200;
const bool wear_on = s.wear_on.load(std::memory_order_relaxed);
Vec along{}, up{};
const auto contact = known && board_axes(s, along, up) ? contact_of(along, up, {v[0], v[1], v[2]}) : Contact::grind;
if (air && known) w.air_down = -v[1];
if (w.was_air && (rolling || grinding)) {
const float down = w.air_down;
if (wear_on) add_physics_wear(s, rolling ? landing_wear(down) : rail_wear(down, contact != Contact::grind));
const bool can_break = s.has_deck.load(std::memory_order_relaxed) && !s.rail_snap.load(std::memory_order_acquire);
// A done board gives way on a landing hard enough for how worn it is.
if (can_break && rolling && wear_on && !s.landing_give.load(std::memory_order_acquire) &&
gives_way_on_landing(down, s.sensitivity.load(std::memory_order_relaxed), s.wear_now.load(std::memory_order_relaxed))) {
s.landing_speed.store(down, std::memory_order_relaxed);
s.landing_give.store(true, std::memory_order_release);
}
if (can_break && grinding && contact != Contact::grind && s.rails.load(std::memory_order_relaxed)) {
const float chance = s.always.load(std::memory_order_relaxed) ? (down > 1.5f ? 1.0f : 0.0f)
: rail_chance(down, contact, s.sensitivity.load(std::memory_order_relaxed),
s.wear_now.load(std::memory_order_relaxed));
if (std::uniform_real_distribution<float>(0.0f, 1.0f)(roll) < chance) {
s.rail_contact.store(static_cast<int>(contact), std::memory_order_relaxed);
s.rail_speed.store(down, std::memory_order_relaxed);
s.rail_snap.store(true, std::memory_order_release);
// The board snaps under the rider: the rider goes down with it. Gently: the game reads the
// cause's force as how hard it was hit, and a slam's force (10 and more) with the board
// flat on the ground threw the rider high into the air.
no_bail_request_wipeout(2.5f);
}
}
}
if (grinding && wear_on) add_physics_wear(s, grind_wear_per_second(contact != Contact::grind) * seconds);
w.was_air = air;
}
// The body's hits in a bail, each where and as hard as it hit.
if (s.body_sounds.load(std::memory_order_relaxed) && hall_of_meat::skater_available()) {
const auto step = hall_of_meat::read_step(skater, seconds, wipeout);
if (step.ragdoll.value_or(false)) {
const auto now = GetTickCount64();
for (std::size_t i = 1; i < skater_body::count; ++i) {
const auto& c = step.body.bodies[i];
if (skater_body::foot(static_cast<skater_body::Bone>(i)) || !c.touching || c.impact < body_hit) continue;
if (now - w.body_at[i] < 220 || now - w.any_at < 70) continue;
w.body_at[i] = w.any_at = now;
audio::play(audio::Kind::body, body_loudness(c.impact));
break;
}
}
}
SetLastError(error);
}
// ---- breaking and mending --------------------------------------------------------------------------
// `carried`: the skater is on foot with the board. The grip tape always holds then: a piece that
// came off would have to fall to the ground under the skater's feet, and where that is the board
// in a hand does not tell (a loose piece takes the ground from where the board rolled or lies).
bool snap(State& s, std::uintptr_t board, std::optional<Place> at, bool gave_way, bool carried, std::string_view why, bool asked = false) {
if (!s.has_deck.load(std::memory_order_acquire) || s.broken || !board) return false;
const auto place = at ? *at : static_cast<Place>(std::uniform_int_distribution<int>(0, 2)(s.random));
const bool joined = carried || std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < joined_share;
const bool joined = carried || std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < settings_now().joined / 100.0f;
const auto now = GetTickCount64();
s.parts = broken_at(place, joined);
s.angle = s.parts.hinge == Hinge::fold ? std::uniform_real_distribution<float>(0.35f, 1.0f)(s.random)
: std::uniform_real_distribution<float>(0.30f, 0.65f)(s.random);
s.break_ground = s.ride_when && now - s.ride_when < 400 ? s.ride_height : std::numeric_limits<float>::quiet_NaN();
++s.flights;
s.broken = Broken{board, now, gave_way, asked};
s.broken = Broken{board, now, gave_way, asked, deck_now(s)};
s.back_since = 0;
crack_sound(s, break_size(place, joined, s.angle));
say(logging::Level::debug, "the board broke at the {}{} ({}).", place_names[static_cast<std::size_t>(place)],
crack_sound(break_size(place, joined, s.angle));
say(logging::Level::info, "the board broke at the {}{} ({}).", place_names[static_cast<std::size_t>(place)],
joined ? ", held together by the grip tape" : "", why);
return true;
}
void mend(State& s, std::string_view why) {
if (!s.broken) return;
say(logging::Level::debug, "the board is whole again after {:.1f} s ({}).", (GetTickCount64() - s.broken->since) / 1000.0, why);
// A board that really broke is a new one now; one broken from the console was only looked at.
if (!s.broken->asked) new_board(s, s.broken->deck);
s.broken.reset();
s.parts = {};
}
// A board that broke on metal (a rail, a bench's edge, a car) sounds like it.
void metal_sound(std::uintptr_t base, std::uintptr_t holder) {
Vec at{};
Quat facing{};
if (!board_place(base, holder, at, facing)) return;
const auto material = material_near(at, 600, 3.0f);
const bool metal = material && is_metal(*material);
if (metal && settings_now().crack_sounds) audio::play(audio::Kind::metal, 1.0f);
say(logging::Level::debug, "it broke on material {}{}.", material ? std::to_string(*material) : std::string("unknown"), metal ? " (metal)" : "");
}
// Where the board is relative to the skater: how far, and how flat it lies (1 = flat on the ground
// either way up, 0 = on its edge, as when it is carried).
struct Hold {
@@ -473,6 +974,16 @@ bool start(std::uintptr_t base) noexcept {
}
s.base.store(base, std::memory_order_release);
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release);
{
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
mirror(s, f.settings);
}
// Rail snaps, wear and the body's hits come from each physics step of the local skater.
if (!add_no_bail_step_listener(&on_step))
logging::write(logging::Level::warning, logging::Channel::skater,
"Board Break: No Bail has no room for its physics step listener: no rail snaps, wear or body sounds.");
const auto in_game = mods::board_break_in_play();
{
std::lock_guard lock(s.names);
@@ -545,11 +1056,32 @@ void on_client_tick(std::uintptr_t client) noexcept {
wanted.swap(s.wanted_deck);
}
s.has_deck.store(in_game, std::memory_order_release);
if (changed || !s.setup_read) {
s.setup_read = true;
const auto setup = read_deck_setup(deck_in_game);
render_edit().axle.store(setup.axle, std::memory_order_release);
{
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
const auto worn = f.wear.find(deck_in_game);
s.wear_now.store(worn == f.wear.end() ? 0.0f : worn->second, std::memory_order_relaxed);
}
if (in_game) load_sounds();
if (in_game && setup.axle != 1.0f)
say(logging::Level::info, "the {} deck's wheels sit {:.2f} as far apart as a standard board's.", deck_label(deck_in_game), setup.axle);
}
if (changed) say(logging::Level::info, "the deck in the game is now: {}{}.", deck_label(deck_in_game), in_game ? "" : " (boards cannot break)");
if (!wanted.empty() && wanted != deck_in_game && mods::save_board_break_deck(wanted)) {
mend(s, "another deck");
say(logging::Level::info, "deck {} chosen. {}", deck_label(wanted), live_mods::apply(true));
}
if (NoBailSkater probe; in_game && !s.effects_hooked && no_bail_skater(probe)) {
// What a board broke on comes from the game's contact effects (native_vfx.h). The hook is
// installed once, in a world (multiplayer would do it there too).
s.effects_hooked = true;
multiplayer::prepare_effects(base);
}
if (in_game && !s.hooked) {
// The boards' render pose hook is shared with multiplayer and first person, and only
// installed when one of them asks.
@@ -579,7 +1111,37 @@ void on_client_tick(std::uintptr_t client) noexcept {
return;
}
s.board_seen = now;
note_axes(s, base, holder);
if (s.broken && s.broken->board != board) mend(s, "the skater has another board");
// What wear has done, onto the board; and what that does to its handling.
take_wear(s, deck_in_game);
if (now - s.handled_at > 500) {
s.handled_at = now;
if (now - s.session_checked > 2000) {
s.session_checked = now;
const auto session = multiplayer::model();
s.in_session = session.active || session.connected;
}
const auto st = settings_now();
const bool worn = s.on.load(std::memory_order_acquire) && st.wear && !s.in_session;
apply_handling(base, skater.entity, worn ? handling(s.wear_now.load(std::memory_order_relaxed)) : Handling{});
}
if (now - s.saved_at > 5000) {
s.saved_at = now;
auto& f = feel();
std::lock_guard lock(f.mutex);
if (f.dirty) save_feel(f);
}
// A board that came down too hard onto a rail in a slide (the physics thread saw it).
if (s.rail_snap.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken) {
const auto contact = static_cast<std::size_t>(s.rail_contact.load(std::memory_order_relaxed));
const auto what = std::format("a {} onto a rail at {:.1f} m/s", contact_names[(std::min)(contact, contact_names.size() - 1)], s.rail_speed.load());
if (snap(s, board, Place::mid, false, false, what)) metal_sound(base, holder);
}
// A done board that landed too hard (the physics thread saw it): the nose goes, and they ride on.
if (s.landing_give.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken)
(void)snap(s, board, Place::nose, true, false,
std::format("a landing at {:.1f} m/s on a board that was done", s.landing_speed.load(std::memory_order_relaxed)));
watch_physics_state(client, skater.entity);
const auto watch = watched_physics_state();
if (s.wanted_mend.exchange(false, std::memory_order_acq_rel)) mend(s, "asked for");
@@ -608,16 +1170,20 @@ void on_client_tick(std::uintptr_t client) noexcept {
if (hit->reason.load() < 0 || now - hit->at.load() > recent_ms) hit = &last_cause();
const bool known = now - hit->at.load() <= recent_ms && hit->reason.load() >= 0;
const auto force = known ? hit->force.load() : 0.0f;
if (on && !s.broken && std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < break_chance(force, heavy, light))
(void)snap(s, board, std::nullopt, false, watch.previous >= 500 && watch.previous < 600, std::format("a slam of force {:.1f}", force));
const auto st = settings_now();
if (on && st.wear) take_wear(s, deck_in_game, slam_wear(force));
const float chance = st.always ? 1.0f : slam_chance(force, st.sensitivity, s.wear_now.load(std::memory_order_relaxed));
if (on && !s.broken && std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < chance) {
// Nose first it goes at the nose, tail first at the tail, flat mostly in the middle.
Vec along{}, up{};
const float slope = board_axes(s, along, up) ? -along.y : 0.0f;
const auto place = place_of_break(slope, false, std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random));
if (snap(s, board, place, false, watch.previous >= 500 && watch.previous < 600,
std::format("a slam of force {:.1f}{}", force, st.always ? ", set to always break" : "")))
metal_sound(base, holder);
}
}
// Now and then a board simply gives way under the rider: the nose goes.
if (on && !s.broken && s.rode_at && riding(watch.state)) {
const auto seconds = (std::min<std::uint64_t>)(now - s.rode_at, 250) / 1000.0;
if (std::uniform_real_distribution<double>(0.0, 1.0)(s.random) < gives_way * seconds)
(void)snap(s, board, Place::nose, true, false, "it gave way while riding");
}
s.rode_at = now;
if (s.broken) {
// Whole again once the skater rides it, or has picked it up after the fall. A board that
// gave way under the rider stays as it is while it is ridden; after a fall it is a
@@ -660,6 +1226,7 @@ std::string deck() {
}
std::string deck_label(std::string_view deck) {
if (deck.empty()) return "None";
if (auto title = setup_of(deck).label; !title.empty()) return title;
std::string name(deck);
bool start = true;
for (auto& c : name) {
@@ -688,4 +1255,84 @@ std::string choose_deck(std::string wanted) {
}
void snap(int place) noexcept { state().wanted_snap.store(place >= 0 && place < 3 ? place : 3, std::memory_order_release); }
void mend() noexcept { state().wanted_mend.store(true, std::memory_order_release); }
Settings settings() noexcept {
try {
return settings_now();
} catch (...) { return {}; }
}
bool set_setting(std::string_view name, int value) noexcept {
try {
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
if (!set_setting_value(f.settings, name, value)) return false;
mirror(state(), f.settings);
save_feel(f);
return true;
} catch (...) { return false; }
}
float wear() noexcept { return state().wear_now.load(std::memory_order_relaxed); }
void set_wear(float value) noexcept {
try {
auto& s = state();
const auto deck = deck_now(s);
if (deck.empty()) return;
value = std::clamp(value, 0.0f, 1.0f);
{
auto& f = feel();
std::lock_guard lock(f.mutex);
load_feel(f);
f.wear[deck] = value;
save_feel(f);
}
s.wear_now.store(value, std::memory_order_relaxed);
if (value == 0.0f) profile_runtime::reset_board_wear();
} catch (...) {}
}
std::string metal_command(std::string_view action) {
auto& f = feel();
if (action == "mark" || action == "unmark") {
const auto material = material_near({}, 1500, std::numeric_limits<float>::infinity());
if (!material) return "No contacts in the last second and a half: grind the rail first, then mark it.";
std::lock_guard lock(f.mutex);
load_feel(f);
if (action == "mark") f.metal.insert(*material);
else f.metal.erase(*material);
save_feel(f);
return std::format("Material {} is {} metal.", *material, action == "mark" ? "now" : "no longer");
}
std::lock_guard lock(f.mutex);
load_feel(f);
if (action == "clear") {
f.metal.clear();
save_feel(f);
return "No material is marked as metal now (a pack's own list still counts).";
}
std::string list;
for (const auto m : f.metal) list += (list.empty() ? "" : ", ") + std::to_string(m);
std::string pack;
for (const auto m : f.pack_metal) pack += (pack.empty() ? "" : ", ") + std::to_string(m);
return "Metal: " + (list.empty() ? std::string("none marked") : list) + "; from the deck pack: " + (pack.empty() ? std::string("none") : pack) + ".";
}
std::string materials_report() {
std::map<std::uint16_t, int> count;
for (const auto& one : multiplayer::recent_local_impacts(3000)) ++count[one.impact.material];
if (count.empty()) return "No contacts in the last 3 seconds.";
std::string out = "Materials touched in the last 3 s:";
for (const auto& [material, times] : count) out += std::format(" {} ({}x{})", material, times, is_metal(material) ? ", metal" : "");
return out + ".";
}
std::string status() {
auto& s = state();
if (!available()) return "Board Break is unavailable for this game build.";
const auto st = settings_now();
const auto deck = deck_now(s);
const auto worn = s.wear_now.load(std::memory_order_relaxed);
return std::format("Board Break {}; deck {}; the board is {} ({:.0f}% worn); sensitivity {}%, always break {}, rail snaps {}, wear {} at {}%, "
"grip holds {}% of breaks, crack sounds {}, body sounds {}.",
s.on.load() ? "on" : "off", deck_label(deck), condition_name(worn), worn * 100.0f,
st.sensitivity, st.always ? "on" : "off", st.rails ? "on" : "off", st.wear ? "on" : "off", st.wear_rate, st.joined,
st.crack_sounds ? "on" : "off", st.body_sounds ? "on" : "off");
}
} // namespace dingosdk::board_break
+25
View File
@@ -1,5 +1,7 @@
#pragma once
#include "board_break_feel.h"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
@@ -60,4 +62,27 @@ std::string choose_deck(std::string wanted);
// respawns. Or makes a broken board whole.
void snap(int place) noexcept;
void mend() noexcept;
// ---- the feel of it -------------------------------------------------------------------------------
// The player's settings (board_break_feel.h), kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings.
// Any thread; set_setting saves at once and is false for no such setting.
Settings settings() noexcept;
bool set_setting(std::string_view name, int value) noexcept;
// Wear and tear: how worn the board of the deck in the game is (0 new, 1 worn out), kept per deck
// with the settings. A board that broke is a new one once it is whole again. A worn board handles
// worse (softer trucks, a lazier turn: the game's own physics tuning, only outside multiplayer
// sessions, which share tuning), breaks more easily and, worn out, can give way under the rider.
// Its scratches are the game's own board wear (Mod Options > Board wear), reset with it.
float wear() noexcept;
void set_wear(float wear) noexcept;
// Client thread: what wear has set the game's physics tuning value at `offset` (in the tuning
// asset) to, when it is one of the four handling values wear changes and wear has written it; for
// the trainer, which hands such a value back to wear while the player has not changed it.
std::optional<float> handling_value(std::uint16_t offset) noexcept;
// Metal: the level's materials a board breaking on them sounds like metal on. They are learned
// from the contacts while grinding (`boardbreak metal mark` marks what the board is on now as
// metal), and a pack can bring a list (sounds/metal/materials.txt). For the console.
std::string metal_command(std::string_view action);
std::string materials_report();
std::string status();
}
+333
View File
@@ -0,0 +1,333 @@
#include "board_break_audio.h"
#include "Engine/Core/Platform/path_text.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Extension/Multiplayer/Voice/native_voice.h"
#include <Windows.h>
#include <mmsystem.h>
#include <xaudio2.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <map>
#include <atomic>
#include <condition_variable>
#include <cstring>
#include <deque>
#include <fstream>
#include <mutex>
#include <random>
#include <string>
#include <thread>
#pragma comment(lib, "xaudio2.lib")
#pragma comment(lib, "winmm.lib")
#pragma comment(lib, "ole32.lib")
namespace dingosdk::board_break::audio {
namespace {
// One wave file, read whole: its format as the file gives it and its samples, and the same sound
// as the game's positioned sound takes it (16 kHz mono; empty when it could not be made).
struct Sound {
std::filesystem::path file;
std::vector<std::uint8_t> format; // WAVEFORMATEX, or the larger WAVEFORMATEXTENSIBLE
std::vector<std::uint8_t> samples;
std::vector<std::int16_t> voice;
};
// The sound at the game sound's rate, mixed down to one channel: each output sample is the mean of
// the input samples it spans (a plain low-pass, so a crack's highs do not fold back as noise).
std::vector<std::int16_t> to_voice(const Sound& sound) {
WAVEFORMATEX format{};
std::memcpy(&format, sound.format.data(), std::min(sound.format.size(), sizeof(format)));
auto tag = format.wFormatTag;
if (tag == WAVE_FORMAT_EXTENSIBLE && sound.format.size() >= 26) std::memcpy(&tag, sound.format.data() + 24, 2); // its SubFormat
const unsigned channels = format.nChannels, bits = format.wBitsPerSample, rate = format.nSamplesPerSec;
const bool floats = tag == WAVE_FORMAT_IEEE_FLOAT;
if (!channels || !rate || (tag != WAVE_FORMAT_PCM && !floats) || (floats && bits != 32) ||
(!floats && bits != 8 && bits != 16 && bits != 24 && bits != 32))
return {};
const std::size_t stride = channels * (bits / 8), frames = sound.samples.size() / stride;
const auto at = [&](std::size_t frame) {
float sum = 0.0f;
for (unsigned c = 0; c < channels; ++c) {
const auto* p = sound.samples.data() + frame * stride + c * (bits / 8);
float v{};
if (floats) std::memcpy(&v, p, 4);
else if (bits == 8) v = (static_cast<float>(*p) - 128.0f) / 128.0f;
else if (bits == 16) { std::int16_t s{}; std::memcpy(&s, p, 2); v = s / 32768.0f; }
else if (bits == 24) v = static_cast<float>(static_cast<std::int32_t>((p[0] << 8) | (p[1] << 16) | (p[2] << 24)) >> 8) / 8388608.0f;
else { std::int32_t s{}; std::memcpy(&s, p, 4); v = static_cast<float>(s) / 2147483648.0f; }
sum += v;
}
return sum / static_cast<float>(channels);
};
const double step = static_cast<double>(rate) / multiplayer::native_voice_rate;
const auto count = static_cast<std::size_t>(static_cast<double>(frames) / step);
std::vector<std::int16_t> out(count);
for (std::size_t i = 0; i < count; ++i) {
const auto from = static_cast<std::size_t>(static_cast<double>(i) * step);
const auto to = std::min(frames, std::max(from + 1, static_cast<std::size_t>(static_cast<double>(i + 1) * step)));
float sum = 0.0f;
for (auto f = from; f < to; ++f) sum += at(f);
const float v = std::isfinite(sum) ? sum / static_cast<float>(to - from) : 0.0f;
out[i] = static_cast<std::int16_t>(std::clamp(v, -1.0f, 1.0f) * 32767.0f);
}
return out;
}
// A RIFF WAVE file's "fmt " and "data" chunks; false for anything else.
bool read_wave(const std::filesystem::path& file, Sound& out) {
std::ifstream in(file, std::ios::binary);
std::vector<std::uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
if (bytes.size() < 12 || std::memcmp(bytes.data(), "RIFF", 4) || std::memcmp(bytes.data() + 8, "WAVE", 4)) return false;
std::size_t at = 12;
while (at + 8 <= bytes.size()) {
std::uint32_t size{};
std::memcpy(&size, bytes.data() + at + 4, 4);
const auto body = at + 8;
if (body + size > bytes.size()) size = static_cast<std::uint32_t>(bytes.size() - body);
if (!std::memcmp(bytes.data() + at, "fmt ", 4) && size >= 16) out.format.assign(bytes.begin() + body, bytes.begin() + body + size);
else if (!std::memcmp(bytes.data() + at, "data", 4)) out.samples.assign(bytes.begin() + body, bytes.begin() + body + size);
at = body + size + (size & 1);
}
if (out.format.size() < 16 || out.samples.empty()) return false;
std::uint16_t tag{};
std::memcpy(&tag, out.format.data(), 2);
if (tag != WAVE_FORMAT_PCM && tag != WAVE_FORMAT_IEEE_FLOAT && tag != WAVE_FORMAT_EXTENSIBLE) return false;
// XAudio2 reads a whole WAVEFORMATEX (cbSize included).
if (out.format.size() < sizeof(WAVEFORMATEX)) out.format.resize(sizeof(WAVEFORMATEX), 0);
out.file = file;
return true;
}
struct Request {
Kind kind{};
float volume{};
};
// A sound the game's positioned sound is to play.
struct GameRequest {
const Sound* sound{};
float volume{};
};
long long now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
struct Engine {
std::mutex mutex;
std::condition_variable wake;
std::deque<Request> queue;
std::deque<GameRequest> game_queue;
// When the game's sound last could play them (tick_game_sound): till then, and a moment after
// it stops (a level left), the speakers do.
std::atomic<long long> game_ms{};
std::array<std::vector<Sound>, kinds> sounds;
std::array<bool, kinds> loaded{};
std::thread worker;
std::atomic<bool> started{}, failed{};
std::mt19937 random{std::random_device{}()};
};
Engine& engine() {
static auto* value = new Engine;
return *value;
}
void run(Engine& e) {
const auto com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
IXAudio2* xaudio{};
IXAudio2MasteringVoice* master{};
if (FAILED(XAudio2Create(&xaudio, 0, XAUDIO2_DEFAULT_PROCESSOR)) || FAILED(xaudio->CreateMasteringVoice(&master))) {
if (xaudio) xaudio->Release();
xaudio = nullptr;
e.failed.store(true, std::memory_order_release);
logging::log(logging::Level::warning, logging::Channel::skater,
"Board Break: no sound device for its own sounds; the cracks play one at a time.");
}
std::vector<IXAudio2SourceVoice*> voices;
for (;;) {
Request request;
const Sound* sound{};
{
std::unique_lock lock(e.mutex);
e.wake.wait(lock, [&] { return !e.queue.empty(); });
request = e.queue.front();
e.queue.pop_front();
auto& list = e.sounds[static_cast<std::size_t>(request.kind)];
if (list.empty()) continue;
sound = &list[std::uniform_int_distribution<std::size_t>(0, list.size() - 1)(e.random)];
}
if (!xaudio) {
// Without XAudio2: the cracks still sound, one at a time.
if (request.kind <= Kind::heavy) (void)PlaySoundW(sound->file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT);
continue;
}
// Voices whose sound has played out go; at most a couple of dozen at once.
std::erase_if(voices, [](IXAudio2SourceVoice* voice) {
XAUDIO2_VOICE_STATE state{};
voice->GetState(&state, XAUDIO2_VOICE_NOSAMPLESPLAYED);
if (state.BuffersQueued) return false;
voice->DestroyVoice();
return true;
});
if (voices.size() >= 24) {
voices.front()->DestroyVoice();
voices.erase(voices.begin());
}
IXAudio2SourceVoice* voice{};
if (FAILED(xaudio->CreateSourceVoice(&voice, reinterpret_cast<const WAVEFORMATEX*>(sound->format.data())))) continue;
XAUDIO2_BUFFER buffer{};
buffer.AudioBytes = static_cast<UINT32>(sound->samples.size());
buffer.pAudioData = sound->samples.data();
buffer.Flags = XAUDIO2_END_OF_STREAM;
voice->SetVolume(std::clamp(request.volume, 0.0f, 1.0f));
if (FAILED(voice->SubmitSourceBuffer(&buffer)) || FAILED(voice->Start())) {
voice->DestroyVoice();
continue;
}
voices.push_back(voice);
}
(void)com;
}
}
void load(Kind kind, const std::vector<std::filesystem::path>& files) noexcept {
auto& e = engine();
std::vector<Sound> read;
for (const auto& file : files) {
try {
Sound sound;
if (read_wave(file, sound)) {
sound.voice = to_voice(sound);
read.push_back(std::move(sound));
}
else logging::log(logging::Level::warning, logging::Channel::skater, "Board Break: {} is not a wave file it can play.",
path_utf8(file.filename()));
} catch (...) {}
}
std::lock_guard lock(e.mutex);
const auto at = static_cast<std::size_t>(kind);
if (e.loaded[at]) return;
e.loaded[at] = true;
e.sounds[at] = std::move(read);
}
bool has(Kind kind) noexcept {
auto& e = engine();
std::lock_guard lock(e.mutex);
return !e.sounds[static_cast<std::size_t>(kind)].empty();
}
void play(Kind kind, float volume) noexcept {
auto& e = engine();
try {
// In a level: the game's own sound plays it, at the skater (tick_game_sound).
if (now_ms() - e.game_ms.load(std::memory_order_relaxed) < 400) {
std::lock_guard lock(e.mutex);
auto& list = e.sounds[static_cast<std::size_t>(kind)];
if (list.empty()) return;
const auto& sound = list[std::uniform_int_distribution<std::size_t>(0, list.size() - 1)(e.random)];
if (!sound.voice.empty()) {
if (e.game_queue.size() < 32) e.game_queue.push_back({&sound, volume});
return;
}
}
if (!e.started.exchange(true)) {
e.worker = std::thread([&e] { run(e); });
e.worker.detach();
}
std::lock_guard lock(e.mutex);
if (e.queue.size() < 32) e.queue.push_back({kind, volume});
} catch (...) { return; }
e.wake.notify_one();
}
namespace {
using Clock = std::chrono::steady_clock;
// Board Break's sounds in the game's sample provider ("RSKB"), apart from voices and drones.
constexpr std::uint64_t source_tag = 0x52534b4200000000ULL;
// Their loudness through the game's sound, against the speakers' (1: the same samples).
constexpr float game_level = 1.0f;
// Sound made ahead for a sound just started, so its first blocks do not run dry; at most this
// much in one frame; and how long a finished sound keeps its place for its last blocks to play.
constexpr double preroll_seconds = 0.06;
constexpr float longest_frame = 0.1f;
constexpr float tail_seconds = 0.4f;
struct Stream {
const Sound* sound{};
float gain{};
std::size_t cursor{};
double owed{};
float tail{};
};
struct Player {
multiplayer::NativeVoicePlayback playback{source_tag};
std::map<std::uint64_t, Stream> streams;
std::uint64_t next_id = 1;
bool started{};
Clock::time_point last{};
};
// Never destroyed: its sounds belong to the game, which is gone by then.
Player& player() {
static auto* value = new Player;
return *value;
}
}
void tick_game_sound(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
auto& e = engine();
auto& p = player();
try {
const auto now = Clock::now();
if (!p.started) {
p.playback.reset(1);
p.started = true;
p.last = now;
}
const float elapsed = std::clamp(std::chrono::duration<float>(now - p.last).count(), 0.0f, longest_frame);
p.last = now;
{
std::lock_guard lock(e.mutex);
for (const auto& request : e.game_queue)
if (ready && p.streams.size() < 24) {
Stream stream;
stream.sound = request.sound;
stream.gain = std::clamp(request.volume, 0.0f, 1.0f) * game_level;
stream.owed = preroll_seconds * multiplayer::native_voice_rate;
p.streams.emplace(p.next_id++, stream);
}
e.game_queue.clear();
}
std::array<float, 3> at{};
bool placed{};
if (ready && !p.streams.empty()) {
const auto frame = multiplayer::capture_local(base, client, false);
if (frame.ready) {
at = {frame.pose.root.position[0], frame.pose.root.position[1] + 0.3f, frame.pose.root.position[2]};
placed = true;
}
}
if (!placed) p.streams.clear(); // (their sounds are let go once they have no place for a moment)
std::vector<std::int16_t> block;
for (auto it = p.streams.begin(); it != p.streams.end();) {
auto& stream = it->second;
const auto& samples = stream.sound->voice;
p.playback.position(it->first, 1, at, stream.gain);
if (stream.cursor >= samples.size()) {
stream.tail += elapsed;
if (stream.tail >= tail_seconds) { it = p.streams.erase(it); continue; }
++it;
continue;
}
stream.owed += static_cast<double>(elapsed) * multiplayer::native_voice_rate;
const auto count = std::min({static_cast<std::size_t>(stream.owed), samples.size() - stream.cursor,
static_cast<std::size_t>(multiplayer::native_voice_rate * longest_frame)});
stream.owed -= static_cast<double>(count);
if (count) {
const auto* data = reinterpret_cast<const std::uint8_t*>(samples.data() + stream.cursor);
if (p.playback.submit(it->first, 1, {data, count * sizeof(std::int16_t)})) stream.cursor += count;
}
++it;
}
p.playback.process(base);
if (ready && p.playback.available()) e.game_ms.store(now_ms(), std::memory_order_relaxed);
} catch (...) {
p.streams.clear();
}
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <vector>
// Board Break's sounds: wave files out of a deck pack's `sounds` folder, over each other and at the
// loudness asked for, so a crack, the board's metal and the body's hits of one bail can all be heard.
// In a level they play through the game's own positioned sound, where the skater is, so the game's
// volume, mix and echo apply (16 kHz mono, as that sound takes). Elsewhere, or until the game's
// sound is ready, straight to the speakers: XAudio2 on a thread of its own, and without it (no
// sound device) the cracks one at a time with PlaySound.
namespace dingosdk::board_break::audio {
enum class Kind { light, medium, heavy, metal, body };
inline constexpr std::size_t kinds = 5;
// The wave files of a kind (16-bit or float PCM, any rate and channels). Any thread; a kind is
// given its files once, later calls for it change nothing.
void load(Kind kind, const std::vector<std::filesystem::path>& files) noexcept;
bool has(Kind kind) noexcept;
// One of them, at random, at `volume` (0 to 1). Any thread; returns at once.
void play(Kind kind, float volume) noexcept;
// Client thread, every frame (Runtime/client_tick.cpp): plays what play() gave the game's sound,
// at the local skater. `ready`: a level is loaded and the skater can be read.
void tick_game_sound(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept;
}
@@ -71,5 +71,78 @@ void register_board_break_commands(Commands &registry) {
out("Mended.");
};
registry.add(std::move(mend));
// The player's settings (board_break_feel.h), kept: with no value each says what it is.
struct Setting {
const char *name, *description;
};
const Setting settings[]{
{"always", "Board Break: every slam breaks the board, and every slide that comes down onto a rail (for replay clips)"},
{"sensitivity", "Board Break: how easily a board breaks, percent (100; 200 takes half the force, 50 twice as much)"},
{"joined", "Board Break: percent of breaks the grip tape holds together (50)"},
{"rails", "Board Break: a boardslide or darkslide that comes down too hard onto a rail can snap the board"},
{"wear", "Board Break: boards wear out with every hit, handle worse and break more easily as they do"},
{"wearrate", "Board Break: how fast boards wear, percent (100)"},
{"cracks", "Board Break: the crack (and metal) sound of a break"},
{"bodysounds", "Board Break: the body's hits in a bail, where and as hard as it hits"},
};
for (const auto &setting : settings) {
auto entry = action(std::string("boardbreak ") + setting.name, setting.description, Group::gameplay,
{argument("value", Type::text, true)});
entry.run = [name = std::string(setting.name)](const Model &, const Values &values, const Output &out) {
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
if (!text || text->empty()) {
out(name + " is " + std::to_string(board_break::setting_value(board_break::settings(), name)) + ".");
return;
}
int value{};
if (*text == "on" || *text == "yes") value = 1;
else if (*text == "off" || *text == "no") value = 0;
else {
try {
value = std::stoi(*text);
} catch (...) {
out("Give a number (or on/off).");
return;
}
}
out(board_break::set_setting(name, value) ? name + " is now " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "."
: "No such setting.");
};
registry.add(std::move(entry));
}
auto worn = action("boardbreak worn", "How worn the board of the deck in the game is, percent; 0 is a new board", Group::gameplay,
{argument("percent", Type::text, true)});
worn.run = [](const Model &, const Values &values, const Output &out) {
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
if (text && !text->empty()) {
try {
board_break::set_wear(std::stof(*text) / 100.0f);
} catch (...) {
out("Give a percent, 0 to 100.");
return;
}
}
const auto wear = board_break::wear();
out("The board is " + std::string(board_break::condition_name(wear)) + ", " + std::to_string(static_cast<int>(wear * 100.0f + 0.5f)) + "% worn.");
};
registry.add(std::move(worn));
auto status = action("boardbreak status", "Everything Board Break is set to, and how worn the board is", Group::gameplay);
status.run = [](const Model &, const Values &, const Output &out) { out(board_break::status()); };
registry.add(std::move(status));
auto metal = action("boardbreak metal", "Which materials a board breaking on them sounds like metal on: mark (what you grind now), unmark, clear, list",
Group::gameplay, {argument("mark|unmark|clear|list", Type::text, true)});
metal.run = [](const Model &, const Values &values, const Output &out) {
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
out(board_break::metal_command(text ? *text : std::string("list")));
};
registry.add(std::move(metal));
auto materials = action("boardbreak materials", "The level's materials your skater touched in the last 3 seconds, by number (to find a rail's)",
Group::gameplay);
materials.run = [](const Model &, const Values &, const Output &out) { out(board_break::materials_report()); };
registry.add(std::move(materials));
}
} // namespace dingosdk::console
+178
View File
@@ -0,0 +1,178 @@
#pragma once
#include "board_break_model.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <string_view>
// How a board wears and how hard it has to be hit to break, apart from the game: the player's
// settings, the damage each kind of hit does, what a worn board does to its handling, and what kind
// of contact a board makes with a rail. Plain numbers, so the tests can hold them.
namespace dingosdk::board_break {
// ---- the player's settings ----------------------------------------------------------------------
// Each is kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings and set from the console as
// `boardbreak <name> <value>` (the menus send the same).
struct Settings {
bool always = false; // every slam breaks the board, for replay clips
int sensitivity = 100; // percent: at 200 half the force breaks a board, at 50 twice as much
int joined = 50; // percent of breaks the grip tape holds together
bool rails = true; // a hard boardslide or darkslide onto a rail or ledge can break it
bool wear = true; // boards wear out, handle worse and break more easily as they do
int wear_rate = 100; // percent: how fast they wear
bool crack_sounds = true;
bool body_sounds = true; // the body's hits during a bail
};
inline constexpr int sensitivity_min = 10, sensitivity_max = 400, rate_min = 0, rate_max = 500;
struct SettingInfo {
std::string_view name;
bool on_off; // else a percent
int low, high;
};
inline constexpr std::array<SettingInfo, 8> setting_info{{
{"always", true, 0, 1}, {"sensitivity", false, sensitivity_min, sensitivity_max}, {"joined", false, 0, 100},
{"rails", true, 0, 1}, {"wear", true, 0, 1}, {"wearrate", false, rate_min, rate_max},
{"cracks", true, 0, 1}, {"bodysounds", true, 0, 1},
}};
// The setting `name` as a number (on/off as 1/0), or -1 for no such setting.
inline int setting_value(const Settings& s, std::string_view name) noexcept {
if (name == "always") return s.always;
if (name == "sensitivity") return s.sensitivity;
if (name == "joined") return s.joined;
if (name == "rails") return s.rails;
if (name == "wear") return s.wear;
if (name == "wearrate") return s.wear_rate;
if (name == "cracks") return s.crack_sounds;
if (name == "bodysounds") return s.body_sounds;
return -1;
}
// Sets it, kept within its range; false for no such setting.
inline bool set_setting_value(Settings& s, std::string_view name, int value) noexcept {
for (const auto& info : setting_info) {
if (info.name != name) continue;
value = std::clamp(value, info.low, info.high);
if (name == "always") s.always = value != 0;
else if (name == "sensitivity") s.sensitivity = value;
else if (name == "joined") s.joined = value;
else if (name == "rails") s.rails = value != 0;
else if (name == "wear") s.wear = value != 0;
else if (name == "wearrate") s.wear_rate = value;
else if (name == "cracks") s.crack_sounds = value != 0;
else s.body_sounds = value != 0;
return true;
}
return false;
}
// ---- how hard a slam has to be ------------------------------------------------------------------
// The slam forces measured in play run from about 2 (a stumble) to 10 and more (a hard slam).
inline constexpr float heavy_slam = 10.0f;
// A worn board takes less: at the end of its life about half the force it took new.
inline constexpr float worn_strength = 0.5f;
// How likely a slam of `force` breaks the board, with the player's sensitivity (percent) and the
// board's wear (0 new, 1 worn out). A new board at 100%: never under 6, half the time at 8, always
// from 10. A bail whose force is not known (0) never breaks it.
inline float slam_chance(float force, int sensitivity, float wear) noexcept {
if (!(force > 0.0f)) return 0.0f;
const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f;
const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f);
return break_chance(force, heavy_slam * strength / s);
}
// ---- wear and tear ------------------------------------------------------------------------------
// A board's wear runs from 0 (new) to 1 (worn out). Every hit adds some, at the player's rate
// (percent of these). A fresh board lasts a good while: a few hundred hard landings, or some
// dozens of big drops, or half an hour of boardslides.
// A landing, by how fast the board was coming down as it touched (m/s): nothing up to an ollie off
// a curb's speed, then more and more.
inline constexpr float landing_free = 3.5f;
inline float landing_wear(float down_speed) noexcept {
const float over = (std::max)(0.0f, down_speed - landing_free);
return (std::min)(0.0008f * over * over, 0.04f);
}
// Onto a rail or ledge: the board's own hit, harder on a slide (the deck takes it) than on the trucks.
inline float rail_wear(float down_speed, bool slide) noexcept {
const float hit = landing_wear(down_speed + (slide ? 1.0f : 0.0f));
return hit + (slide ? 0.001f : 0.0003f);
}
// Every second of grinding or sliding.
inline float grind_wear_per_second(bool slide) noexcept { return slide ? 0.0005f : 0.00015f; }
// A slam, by its force.
inline float slam_wear(float force) noexcept { return std::clamp((force - 4.0f) / 6.0f, 0.0f, 1.0f) * 0.025f; }
inline float add_wear(float wear, float amount, int rate) noexcept {
return std::clamp(wear + amount * std::clamp(rate, rate_min, rate_max) / 100.0f, 0.0f, 1.0f);
}
// What wear does to the board's handling, as factors on the game's own tuning: the steering answers
// less, the trucks' springs and their damping go soft (the board wobbles and leans further), and
// they twist further. Nothing shows until a board is a fifth worn.
struct Handling {
float steering = 1, spring = 1, damping = 1, twist = 1;
bool operator==(const Handling&) const = default;
};
inline Handling handling(float wear) noexcept {
const float w = std::clamp((wear - 0.2f) / 0.8f, 0.0f, 1.0f);
const float eased = w * w * (3.0f - 2.0f * w);
return {1.0f - 0.25f * eased, 1.0f - 0.6f * eased, 1.0f - 0.5f * eased, 1.0f + 0.4f * eased};
}
// What a board's state is called on the menus. A board is "done" from `done_wear` on.
inline constexpr float done_wear = 0.85f;
inline std::string_view condition_name(float wear) noexcept {
return wear < 0.15f ? "new" : wear < 0.4f ? "used" : wear < 0.65f ? "worn" : wear < done_wear ? "beat" : "done";
}
// A done board gives way under the rider on a landing (the nose goes, and they ride on): from a
// big drop (6.5 m/s coming down) as it becomes done, down to any real drop (3.5 m/s, a curb's)
// when it is worn right out. Never a board less worn, and never on flat ground riding: a board
// breaks from something the rider did. Sensitivity as for a slam.
inline bool gives_way_on_landing(float down_speed, int sensitivity, float wear) noexcept {
if (wear < done_wear) return false;
const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f;
const float t = std::clamp((wear - done_wear) / (1.0f - done_wear), 0.0f, 1.0f);
return down_speed >= (6.5f - 3.0f * t) / s;
}
// ---- the board on a rail ------------------------------------------------------------------------
enum class Contact { grind, slide, darkslide };
constexpr std::array<std::string_view, 3> contact_names{"grind", "slide", "darkslide"};
// From the board's long axis and its up axis in the world, and how it moves: a board lying across
// its way (a boardslide, a lipslide) slides, one on its grip is in a darkslide, the rest grind on
// their trucks. A board that hardly moves grinds.
inline Contact contact_of(Vec along, Vec up, Vec velocity) noexcept {
if (up.y < -0.35f) return Contact::darkslide;
const float a = std::hypot(along.x, along.z), v = std::hypot(velocity.x, velocity.z);
if (a < 0.2f || v < 0.5f) return Contact::grind;
const float cosine = std::abs((along.x * velocity.x + along.z * velocity.z) / (a * v));
return cosine < 0.5f ? Contact::slide : Contact::grind;
}
// How likely a board snaps as it comes down onto a rail in a slide, by how fast it was coming down
// (m/s): from 3 m/s up (a hop onto a rail is about 2), certain 3 m/s above that; a darkslide on the
// grip goes sooner. Grinds on the trucks do not snap a board. Sensitivity and wear as for a slam.
inline float rail_chance(float down_speed, Contact contact, int sensitivity, float wear) noexcept {
if (contact == Contact::grind) return 0.0f;
const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f;
const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f);
const float start = (contact == Contact::darkslide ? 2.2f : 3.0f) * strength / s;
const float span = 3.0f * strength / s;
return std::clamp((down_speed - start) / span, 0.0f, 1.0f);
}
// ---- where a board breaks -----------------------------------------------------------------------
// From how the board came down: nose first it breaks at the nose, tail first at the tail, flat (or
// across a rail) in the middle. `slope` is how far the nose points down (the board's long axis'
// downward part, -1 to 1); `roll` is 0 to 1, chance. Flat landings still break at an end now and
// then, as real boards do.
inline Place place_of_break(float slope, bool across_rail, float roll) noexcept {
if (across_rail) return Place::mid;
if (slope > 0.35f) return roll < 0.75f ? Place::nose : Place::mid;
if (slope < -0.35f) return roll < 0.75f ? Place::tail : Place::mid;
return roll < 0.5f ? Place::mid : roll < 0.75f ? Place::nose : Place::tail;
}
// ---- a body's hit, for its sound -----------------------------------------------------------------
// The ragdoll's hits along their normal (Extension/HallOfMeat/hall_of_meat_model.h): a graze is 2 to
// 3 m/s, a fall on flat ground 3 to 9, a drop from height 13 and more. Below `body_hit` nothing is
// heard; the loudness rises to full at `body_full`.
inline constexpr float body_hit = 3.0f, body_full = 11.0f;
inline float body_loudness(float speed) noexcept {
if (speed < body_hit) return 0.0f;
return std::clamp(0.3f + 0.7f * (speed - body_hit) / (body_full - body_hit), 0.3f, 1.0f);
}
}
+6 -4
View File
@@ -82,12 +82,14 @@ constexpr Size break_size(Place place, bool joined, float fold_depth) noexcept {
if (place == Place::mid) return !joined || fold_depth >= 0.7f ? Size::heavy : Size::medium;
return joined ? Size::light : Size::medium;
}
// How likely a slam of `force` breaks the board: always from `heavy` up, with `light` up to 60% of
// it, rising evenly in between.
constexpr float break_chance(float force, float heavy, float light) noexcept {
// How likely a slam of `force` breaks the board: never up to 60% of `heavy`, always from `heavy` up,
// rising smoothly in between. A board only breaks from a hit the player can feel was a big one.
constexpr float break_chance(float force, float heavy) noexcept {
if (heavy <= 0 || force >= heavy) return 1.0f;
const auto low = 0.6f * heavy;
return force <= low ? light : light + (1.0f - light) * (force - low) / (heavy - low);
if (force <= low) return 0.0f;
const auto t = (force - low) / (heavy - low);
return t * t * (3.0f - 2.0f * t);
}
// ---- where the parts of a broken board lie ------------------------------------------------------
+61
View File
@@ -0,0 +1,61 @@
#include "descriptor_heap_size.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Game/Build/20260929/descriptor_heap_size.h"
#include <Windows.h>
#include <array>
#include <cstring>
namespace dingosdk {
namespace {
namespace contract = addr::descriptor_heap_size;
bool supported_image(std::uintptr_t base) noexcept {
IMAGE_DOS_HEADER dos{};
IMAGE_NT_HEADERS64 nt{};
return base && memory::read(base, dos) && dos.e_magic == IMAGE_DOS_SIGNATURE && dos.e_lfanew > 0 &&
dos.e_lfanew <= 0x1000 && memory::read(base + dos.e_lfanew, nt) && nt.Signature == IMAGE_NT_SIGNATURE &&
nt.OptionalHeader.SizeOfImage == supported_build::game_image_size;
}
bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept {
auto* const destination = reinterpret_cast<void*>(address);
DWORD previous{};
if (!VirtualProtect(destination, size, PAGE_EXECUTE_READWRITE, &previous)) return false;
std::memcpy(destination, bytes, size);
const bool flushed = FlushInstructionCache(GetCurrentProcess(), destination, size) != FALSE;
DWORD discarded{};
return VirtualProtect(destination, size, previous, &discarded) != FALSE && flushed;
}
} // namespace
bool start_descriptor_heap_size(std::uintptr_t base, std::string& error) {
std::array<std::uint8_t, contract::cap_load.size()> actual{};
std::array<std::uint8_t, contract::total_cap.size()> total{};
if (!supported_image(base) || !memory::read_bytes(base + contract::cap_load_rva, actual.data(), actual.size()) ||
actual != contract::cap_load || !memory::read_bytes(base + contract::total_cap_rva, total.data(), total.size()) ||
total != contract::total_cap) {
error = "the renderer's descriptor heap setup does not match the supported game";
return false;
}
// The same divide, with the cap a constant instead of the setting (which is never set):
// div r9d / mov edi, eax / mov ecx, cap / or eax, -1 / nop
std::array<std::uint8_t, contract::cap_load.size()> code{
0x41, 0xf7, 0xf1, 0x8b, 0xf8, 0xb9, 0, 0, 0, 0, 0x83, 0xc8, 0xff, 0x90};
std::memcpy(code.data() + 6, &frame_descriptor_cap, 4);
// The total, a constant, then capped at 1,000,000 as before: mov ecx, total / nop (4 bytes).
std::array<std::uint8_t, contract::total_cap.size()> load{0xb9, 0, 0, 0, 0, 0x0f, 0x1f, 0x40, 0x00};
std::memcpy(load.data() + 1, &heap_descriptor_total, 4);
if (!write_code(base + contract::cap_load_rva, code.data(), code.size()) ||
!write_code(base + contract::total_cap_rva, load.data(), load.size())) {
(void)write_code(base + contract::cap_load_rva, contract::cap_load.data(), contract::cap_load.size());
(void)write_code(base + contract::total_cap_rva, contract::total_cap.data(), contract::total_cap.size());
error = "the heap sizes could not be patched";
return false;
}
return true;
}
} // namespace dingosdk
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
#include <string>
namespace dingosdk {
// Each frame's share of the renderer's shader-visible descriptor heap is capped at 131,072
// descriptors, in a heap of 333,333. A dense level draws more than that in a frame (the Los Santos
// City sublevels with more than about 65,000 mesh sections), and the renderer then writes past
// the end of the heap: shaders read descriptors that are not there and fault or hang the GPU.
// Each heap is allowed D3D12's 1,000,000 instead, and a frame's share is capped at this, which
// makes each frame's heap 644,288 descriptors.
inline constexpr std::uint32_t heap_descriptor_total = 1'000'000;
inline constexpr std::uint32_t frame_descriptor_cap = 524'288;
// Installs before executable entry, while the game is suspended.
bool start_descriptor_heap_size(std::uintptr_t base, std::string& error);
} // namespace dingosdk
+93
View File
@@ -0,0 +1,93 @@
#include "eadp_errors_arena.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Game/Build/20260929/eadp_errors_arena.h"
#include <Windows.h>
#include <array>
#include <cstring>
#include <limits>
namespace dingosdk {
namespace {
namespace contract = addr::eadp_errors_arena;
constexpr std::size_t stub_page_bytes = 0x1000;
bool supported_image(std::uintptr_t base) noexcept {
IMAGE_DOS_HEADER dos{};
IMAGE_NT_HEADERS64 nt{};
return base && memory::read(base, dos) && dos.e_magic == IMAGE_DOS_SIGNATURE && dos.e_lfanew > 0 &&
dos.e_lfanew <= 0x1000 && memory::read(base + dos.e_lfanew, nt) && nt.Signature == IMAGE_NT_SIGNATURE &&
nt.OptionalHeader.SizeOfImage == supported_build::game_image_size;
}
bool rel32(std::uintptr_t next_instruction, std::uintptr_t target, std::int32_t& displacement) noexcept {
const auto delta = static_cast<std::int64_t>(target) - static_cast<std::int64_t>(next_instruction);
if (delta < std::numeric_limits<std::int32_t>::min() || delta > std::numeric_limits<std::int32_t>::max()) return false;
displacement = static_cast<std::int32_t>(delta);
return true;
}
bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept {
auto* const destination = reinterpret_cast<void*>(address);
DWORD previous{};
if (!VirtualProtect(destination, size, PAGE_EXECUTE_READWRITE, &previous)) return false;
std::memcpy(destination, bytes, size);
const bool flushed = FlushInstructionCache(GetCurrentProcess(), destination, size) != FALSE;
DWORD discarded{};
return VirtualProtect(destination, size, previous, &discarded) != FALSE && flushed;
}
} // namespace
bool start_eadp_errors_arena(std::uintptr_t base, std::string& error) {
std::array<std::uint8_t, contract::size_load.size()> actual{};
if (!supported_image(base) || !memory::read_bytes(base + contract::size_load_rva, actual.data(), actual.size()) ||
actual != contract::size_load) {
error = "the arena's factory does not match the supported game";
return false;
}
auto* const stub = static_cast<std::uint8_t*>(memory::allocate_near_image(base, supported_build::game_image_size, stub_page_bytes));
if (!stub) {
error = "no room for its stub near the game";
return false;
}
const auto at = reinterpret_cast<std::uintptr_t>(stub);
// The two moved instructions, with the size capped between them:
// mov eax, [rax+0x7c] / cmp eax, cap / jbe +5 / mov eax, cap / lea rcx, [vtable] / jmp resume
std::array<std::uint8_t, 27> code{0x8b, 0x40, 0x7c, 0x3d, 0, 0, 0, 0, 0x76, 0x05, 0xb8, 0, 0, 0, 0,
0x48, 0x8d, 0x0d, 0, 0, 0, 0, 0xe9, 0, 0, 0, 0};
std::memcpy(code.data() + 4, &eadp_errors_arena_bytes, 4);
std::memcpy(code.data() + 11, &eadp_errors_arena_bytes, 4);
std::int32_t vtable{}, resume{}, into{};
if (!rel32(at + 22, base + contract::vtable_rva, vtable) || !rel32(at + 27, base + contract::resume_rva, resume) ||
!rel32(base + contract::size_load_rva + 5, at, into)) {
VirtualFree(stub, 0, MEM_RELEASE);
error = "its stub is out of reach of the game";
return false;
}
std::memcpy(code.data() + 18, &vtable, 4);
std::memcpy(code.data() + 23, &resume, 4);
std::memset(stub, 0xcc, stub_page_bytes);
std::memcpy(stub, code.data(), code.size());
DWORD previous{};
if (!VirtualProtect(stub, stub_page_bytes, PAGE_EXECUTE_READ, &previous) ||
!FlushInstructionCache(GetCurrentProcess(), stub, stub_page_bytes)) {
VirtualFree(stub, 0, MEM_RELEASE);
error = "its stub could not be made executable";
return false;
}
std::array<std::uint8_t, contract::size_load.size()> jump{0xe9};
std::memcpy(jump.data() + 1, &into, 4);
std::memset(jump.data() + 5, 0x90, jump.size() - 5);
if (!write_code(base + contract::size_load_rva, jump.data(), jump.size())) {
(void)write_code(base + contract::size_load_rva, contract::size_load.data(), contract::size_load.size());
VirtualFree(stub, 0, MEM_RELEASE);
error = "the size load could not be redirected";
return false;
}
return true;
}
} // namespace dingosdk
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace dingosdk {
// The arena EA's error reporting allocates from is 80 MiB, made at startup and kept in RAM, of
// which about 10 MB was in use: reporting is off (ea_service_block, and the EadpErrorsData
// settings in client_tick.cpp), so its reports are never built. It is made before those settings
// can be changed, so its size is capped where the game reads it instead. A smaller configured
// size is left as it is.
inline constexpr std::uint32_t eadp_errors_arena_bytes = 32u << 20;
// Installs before executable entry, while the game is suspended.
bool start_eadp_errors_arena(std::uintptr_t base, std::string& error);
} // namespace dingosdk
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more