Trainer 0.5.0: built on ReSkate 2.0.6

TUNE as one page of folding sections, presets above the tabs, Skate 3 grind and
transition feel, Dark Pop, manual pop height, slow flips pop higher, per-trick
speeds, and a release audit of stock / resets / presets. See Extension/Trainer/CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wk6Y7hSKoBANMm9ZHbStLg
This commit is contained in:
andrewnakasandClaude Opus 5.5 committed 2026-10-11 18:49:24 -06:00
commit f027d319be
391 files changed
+47172 -5566

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
/artifacts/
/logs/
/.vs/
/CMakeUserPresets.json
__pycache__/
*.log
*.dll
+129 -8
View File
@@ -28,6 +28,11 @@ endif()
option(DINGOSDK_BUILD_TESTS "Build logging regression tests" OFF)
option(DINGOSDK_BUILD_PARK_EDITOR_TESTS "Build park editor regression tests" OFF)
option(DINGOSDK_BUILD_TRAINER_TESTS "Build the trainer's tools and regression tests" OFF)
if(DINGOSDK_BUILD_PARK_EDITOR_TESTS)
enable_testing()
add_executable(dingosdk_park_randomization_tests Engine/Game/World/Test/park_randomization_tests.cpp)
add_test(NAME park_randomization COMMAND dingosdk_park_randomization_tests)
endif()
if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32)
enable_testing()
add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp)
@@ -47,6 +52,9 @@ if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32)
add_executable(dingosdk_profile_change_tests Extension/Profile/Test/profile_change_tests.cpp)
target_link_libraries(dingosdk_profile_change_tests PRIVATE dingosdk_local_profile)
add_test(NAME profile_changes COMMAND dingosdk_profile_change_tests)
add_executable(dingosdk_music_favorites_tests Extension/Music/Test/local_music_favorites_tests.cpp)
target_link_libraries(dingosdk_music_favorites_tests PRIVATE dingosdk_local_profile)
add_test(NAME music_favorites COMMAND dingosdk_music_favorites_tests)
add_executable(dingosdk_object_categories_tests Extension/Objects/Test/object_categories_tests.cpp)
target_link_libraries(dingosdk_object_categories_tests PRIVATE dingosdk_local_profile)
add_test(NAME object_categories COMMAND dingosdk_object_categories_tests)
@@ -73,6 +81,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
add_executable(dingosdk_speed_check_tests Server/Test/speed_check_tests.cpp)
target_include_directories(dingosdk_speed_check_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME server_speed_check COMMAND dingosdk_speed_check_tests)
add_executable(dingosdk_board_check_tests Server/Test/board_check_tests.cpp)
target_include_directories(dingosdk_board_check_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME server_board_check COMMAND dingosdk_board_check_tests)
add_executable(dingosdk_server_config_tests Server/Test/server_config_tests.cpp Server/server_config.cpp
Extension/Multiplayer/Net/protocol.cpp)
target_include_directories(dingosdk_server_config_tests PRIVATE "${PROJECT_SOURCE_DIR}")
@@ -84,15 +95,37 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
dingosdk_lz4 dingosdk_zstd)
endif()
add_test(NAME server_config COMMAND dingosdk_server_config_tests)
# Park mods spawned by the server's host, over a transport that is not Steam's.
add_executable(dingosdk_server_park_tests Server/Test/server_park_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_park_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_include_directories(dingosdk_server_park_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
if(WIN32)
target_link_libraries(dingosdk_server_park_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_park_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_park_mods COMMAND dingosdk_server_park_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods")
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)
add_test(NAME server_release COMMAND dingosdk_server_release_tests)
add_executable(dingosdk_global_bans_tests Server/Test/global_bans_tests.cpp Server/global_bans.cpp
Extension/Multiplayer/developer_identity.cpp)
Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp)
target_include_directories(dingosdk_global_bans_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json $<$<BOOL:${WIN32}>:dingosdk_https>)
target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json dingosdk_word_filter $<$<BOOL:${WIN32}>:dingosdk_https>)
add_test(NAME server_global_bans COMMAND dingosdk_global_bans_tests)
add_executable(dingosdk_server_words_tests Server/Test/server_words_tests.cpp Server/server_words.cpp)
target_include_directories(dingosdk_server_words_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_server_words_tests PRIVATE dingosdk_word_filter)
add_test(NAME server_words COMMAND dingosdk_server_words_tests)
add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp)
target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter)
add_test(NAME word_filter COMMAND dingosdk_word_filter_tests)
@@ -101,6 +134,12 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp)
target_link_libraries(dingosdk_multiplayer_bandwidth_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
add_test(NAME multiplayer_bandwidth COMMAND dingosdk_multiplayer_bandwidth_tests)
# With a pose-dump recording as its argument it prints what each encoding costs on real play.
add_executable(dingosdk_pose_codec_tests
Extension/Multiplayer/Test/pose_codec_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp)
target_link_libraries(dingosdk_pose_codec_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
add_test(NAME multiplayer_pose_codec COMMAND dingosdk_pose_codec_tests)
add_executable(dingosdk_multiplayer_lane_tests Extension/Multiplayer/Test/steam_lanes_tests.cpp)
target_include_directories(dingosdk_multiplayer_lane_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
add_test(NAME multiplayer_lanes COMMAND dingosdk_multiplayer_lane_tests)
@@ -109,11 +148,19 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
add_test(NAME multiplayer_prediction COMMAND dingosdk_multiplayer_prediction_tests)
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)
if(EXISTS "${PROJECT_SOURCE_DIR}/tests/first_person_spring_tests.cpp")
add_executable(dingosdk_first_person_spring_tests tests/first_person_spring_tests.cpp)
target_include_directories(dingosdk_first_person_spring_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME first_person_spring COMMAND dingosdk_first_person_spring_tests)
endif()
add_executable(dingosdk_first_person_stabilize_tests Engine/Game/Skater/Test/first_person_stabilize_tests.cpp)
target_include_directories(dingosdk_first_person_stabilize_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME first_person_stabilize COMMAND dingosdk_first_person_stabilize_tests)
add_executable(dingosdk_first_person_cycle_tests Engine/Game/Skater/Test/first_person_cycle_tests.cpp)
target_include_directories(dingosdk_first_person_cycle_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME first_person_cycle COMMAND dingosdk_first_person_cycle_tests)
add_executable(dingosdk_playstation_report_tests Engine/Game/Input/Test/playstation_report_tests.cpp)
target_include_directories(dingosdk_playstation_report_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME playstation_report COMMAND dingosdk_playstation_report_tests)
@@ -124,6 +171,20 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
add_test(NAME physics_tuning COMMAND dingosdk_physics_tuning_tests "${DINGOSDK_TEST_GAME_ROOT}")
add_executable(dingosdk_camera_observer_tests Extension/Skater/Test/camera_observer_tests.cpp)
add_test(NAME camera_observer_recovery COMMAND dingosdk_camera_observer_tests)
add_executable(dingosdk_linear_transform_tests Engine/Game/Abi/Test/linear_transform_tests.cpp)
target_include_directories(dingosdk_linear_transform_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME linear_transform COMMAND dingosdk_linear_transform_tests)
add_executable(dingosdk_draw_packet_tests Engine/Game/Rendering/Test/draw_packet_tests.cpp)
target_include_directories(dingosdk_draw_packet_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME draw_packet COMMAND dingosdk_draw_packet_tests)
add_executable(dingosdk_skinned_mesh_tests Engine/Resource/Test/skinned_mesh_tests.cpp)
target_include_directories(dingosdk_skinned_mesh_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_skinned_mesh_tests PRIVATE dingosdk_frostbite)
add_test(NAME skinned_mesh COMMAND dingosdk_skinned_mesh_tests)
add_executable(dingosdk_game_textures_tests Engine/Vfs/Test/game_textures_tests.cpp)
target_include_directories(dingosdk_game_textures_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_game_textures_tests PRIVATE dingosdk_game_archives)
add_test(NAME game_textures COMMAND dingosdk_game_textures_tests "${DINGOSDK_TEST_GAME_ROOT}")
add_executable(dingosdk_native_menu_view_tests Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp)
add_executable(dingosdk_loading_screen_tests Extension/World/Test/loading_screen_tests.cpp)
target_include_directories(dingosdk_loading_screen_tests PRIVATE "${PROJECT_SOURCE_DIR}")
@@ -142,8 +203,8 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
add_executable(dingosdk_multiplayer_tests
Extension/Multiplayer/Test/protocol_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp Server/steam_server.cpp
Extension/Throwdowns/throwdown_wire.cpp)
target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter)
Extension/Throwdowns/throwdown_wire.cpp Extension/Multiplayer/developer_identity.cpp)
target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter dingosdk_json)
add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests)
add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp
Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp)
@@ -162,20 +223,45 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
Extension/Multiplayer/Test/mesh_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp
Extension/Multiplayer/Session/password.cpp Extension/Multiplayer/Session/party_book.cpp
Extension/Multiplayer/Steam/steam_lobbies.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp)
Extension/Multiplayer/Steam/steam_lobbies.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp
Extension/Multiplayer/word_lists.cpp)
target_link_libraries(dingosdk_multiplayer_mesh_tests PRIVATE dingosdk_logging dingosdk_launcher_support dingosdk_lz4 dingosdk_zstd bcrypt
dingosdk_word_filter)
dingosdk_word_filter dingosdk_json)
add_test(NAME multiplayer_mesh COMMAND dingosdk_multiplayer_mesh_tests)
add_executable(dingosdk_developer_identity_tests
Extension/Multiplayer/Test/developer_identity_tests.cpp Extension/Multiplayer/developer_identity.cpp
Extension/Multiplayer/developer_identity_fetch.cpp)
target_link_libraries(dingosdk_developer_identity_tests PRIVATE dingosdk_json dingosdk_https dingosdk_logging winhttp)
Extension/Multiplayer/developer_identity_fetch.cpp Extension/Multiplayer/word_lists.cpp)
target_link_libraries(dingosdk_developer_identity_tests PRIVATE dingosdk_json dingosdk_https dingosdk_logging dingosdk_word_filter winhttp)
add_test(NAME developer_identity COMMAND dingosdk_developer_identity_tests)
add_executable(dingosdk_settings_lock_tests Engine/Game/Settings/Test/multiplayer_settings_lock_tests.cpp)
target_include_directories(dingosdk_settings_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests)
add_executable(dingosdk_simulation_time_tests Engine/Game/Settings/Test/simulation_time_tests.cpp)
target_include_directories(dingosdk_simulation_time_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME simulation_time COMMAND dingosdk_simulation_time_tests)
add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp)
add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests)
add_executable(dingosdk_ea_service_block_tests Extension/Boot/Test/ea_service_block_tests.cpp)
target_include_directories(dingosdk_ea_service_block_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME ea_service_block COMMAND dingosdk_ea_service_block_tests)
add_executable(dingosdk_discord_presence_tests Extension/Boot/Test/discord_presence_tests.cpp Extension/Boot/discord_presence.cpp)
target_include_directories(dingosdk_discord_presence_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_discord_presence_tests PRIVATE dingosdk_json dingosdk_logging)
add_test(NAME discord_presence COMMAND dingosdk_discord_presence_tests)
# The dedicated server's host under a full server of simulated players, over a transport
# that is not Steam's: what its passes cost, and a hash of what it sent (not run by ctest).
add_executable(dingosdk_server_load_tests Server/Test/server_load_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 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_load_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_include_directories(dingosdk_server_load_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
target_link_libraries(dingosdk_server_load_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)
endif()
# The route and logging tests live in the local, untracked tests/ folder.
option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF)
@@ -211,10 +297,45 @@ if(DINGOSDK_BUILD_TRAINER_TESTS)
add_executable(dingosdk_trainer_session_tests Extension/Trainer/Test/session_extras_tests.cpp Extension/Trainer/trainer_session.cpp)
target_include_directories(dingosdk_trainer_session_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trainer_session_extras COMMAND dingosdk_trainer_session_tests)
# Ground-safe arrivals: the downward ray, the landing watch, and the remembered map waypoint.
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)
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)
endif()
option(DINGOSDK_BUILD_ROAD_RASH_TESTS "Build Road Rash regression tests" OFF)
if(DINGOSDK_BUILD_ROAD_RASH_TESTS)
enable_testing()
# The side of a fall, the marks a count of bails leaves, the items' names and where a decal goes.
add_executable(dingosdk_road_rash_tests Extension/RoadRash/Test/road_rash_tests.cpp)
target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME road_rash COMMAND dingosdk_road_rash_tests)
endif()
option(DINGOSDK_BUILD_BOARD_BREAK_TESTS "Build Board Break regression tests" OFF)
if(DINGOSDK_BUILD_BOARD_BREAK_TESTS)
enable_testing()
# What a break leaves of a board, how likely a slam breaks it, and where its parts lie.
add_executable(dingosdk_board_break_tests Extension/BoardBreak/Test/board_break_tests.cpp)
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_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF)
if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
enable_testing()
# The bail: its phases, what each hit did, the Meat it scores and the card that shows it.
add_executable(dingosdk_hall_of_meat_tests Extension/HallOfMeat/Test/hall_of_meat_tests.cpp
Extension/HallOfMeat/hall_of_meat_model.cpp Extension/HallOfMeat/hall_of_meat_card.cpp)
target_include_directories(dingosdk_hall_of_meat_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME hall_of_meat COMMAND dingosdk_hall_of_meat_tests)
# skate.'s own skeleton mesh, read from the game's data and posed (with a Skate folder).
add_executable(dingosdk_hall_of_meat_skeleton_tests Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp
Extension/HallOfMeat/hall_of_meat_skeleton.cpp)
target_include_directories(dingosdk_hall_of_meat_skeleton_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_hall_of_meat_skeleton_tests PRIVATE dingosdk_game_archives dingosdk_logging)
add_test(NAME hall_of_meat_skeleton COMMAND dingosdk_hall_of_meat_skeleton_tests "${DINGOSDK_TEST_GAME_ROOT}")
endif()
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
foreach(target IN LISTS project_targets)
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz)$")
+2
View File
@@ -215,6 +215,8 @@ template <class Context> class Registry {
token += ' ';
token += args[index++];
}
// "toggle" on an on/off switch: the other of what it is now.
if (arg.type == Type::boolean && equal(token, "toggle")) token = current.value && *current.value == "1" ? "0" : "1";
auto value = parse_value(arg.type, token);
if (!value) {
output("error: Invalid " + arg.name + ": '" + token + "'. Usage: " + usage(*entry));
+9 -1
View File
@@ -6,6 +6,7 @@
#include <Windows.h>
#include <bcrypt.h>
#else
#include <cerrno>
#include <fstream>
#include <openssl/sha.h>
#endif
@@ -221,8 +222,15 @@ std::string sha256_file(const fs::path& path) {
}
return output;
#else
// Name the file and the reason, as open_to_read does on Windows: a server admin reading
// "cannot open file" in the log has no idea which file, or what to fix.
std::ifstream input(path, std::ios::binary);
if (!input) detail::fail("Cannot open file for SHA-256");
if (!input) {
const int error = errno;
throw std::runtime_error("Cannot open " + path_utf8(path.filename()) + " in " +
path_utf8(path.parent_path()) + " for SHA-256: " +
(error ? std::strerror(error) : "unknown error"));
}
SHA256_CTX context;
if (!SHA256_Init(&context)) detail::fail("Cannot initialize SHA-256");
std::vector<unsigned char> bytes(1024 * 1024);
+43
View File
@@ -144,6 +144,39 @@ std::uint64_t offline_steam_id() noexcept {
}
#ifdef _WIN32
namespace {
bool map_option(std::wstring_view argument) {
return argument == L"-map" || argument == L"--map" || argument.starts_with(L"-map=") || argument.starts_with(L"--map=");
}
// The name goes into a console command the game runs: one line of plain text.
std::wstring map_name(std::wstring_view value) {
while (!value.empty() && value.front() == L' ') value.remove_prefix(1);
while (!value.empty() && value.back() == L' ') value.remove_suffix(1);
if (value.empty() || value.size() > 96) fail("-map requires a map's name, at most 96 characters");
for (const auto character : value)
if (character < 0x20 || character > 0x7e || character == L';' || character == L'"')
fail("-map takes a map's name in plain letters, digits and spaces");
return std::wstring(value);
}
} // namespace
std::wstring take_map_option(std::vector<std::wstring>& arguments) {
std::wstring map;
for (std::size_t index = 0; index < arguments.size();) {
const std::wstring_view argument(arguments[index]);
if (!map_option(argument)) { ++index; continue; }
const auto equals = argument.find(L'=');
if (equals != std::wstring_view::npos) map = map_name(argument.substr(equals + 1));
else {
if (index + 1 == arguments.size()) fail("-map requires a map's name");
map = map_name(arguments[index + 1]);
arguments.erase(arguments.begin() + static_cast<std::ptrdiff_t>(index) + 1);
}
arguments.erase(arguments.begin() + static_cast<std::ptrdiff_t>(index));
}
return map;
}
LaunchOptions parse_launch_options(const std::vector<std::wstring>& arguments) {
LaunchOptions options;
bool log_level_explicit = false;
@@ -181,7 +214,17 @@ LaunchOptions parse_launch_options(const std::vector<std::wstring>& arguments) {
if (!logging::parse_level(level)) fail("--log-level requires trace, debug, info, warning, error, critical, or off");
options.log_level = std::move(level); log_level_explicit = true; continue;
}
if (map_option(argument)) {
const auto equals = argument.find(L'=');
if (equals != std::wstring_view::npos) options.map = map_name(argument.substr(equals + 1));
else {
if (++index == arguments.size()) fail("-map requires a map's name");
options.map = map_name(arguments[index]);
}
continue;
}
if (argument == L"--gpu-diagnostics") { options.gpu_diagnostics = true; continue; }
if (argument == L"--no-discord") { options.discord = false; continue; }
const bool menu_key = argument.starts_with(L"--menu-key=");
if (menu_key || argument.starts_with(L"--console-key=")) {
const std::wstring value(argument.substr(argument.find(L'=') + 1));
+8
View File
@@ -42,6 +42,7 @@ struct LaunchOptions {
unsigned height{720};
bool force_windowed{};
bool gpu_diagnostics{};
bool discord{true}; // the game may show a Discord status (--no-discord: never)
bool window_console{};
bool log_trace{};
bool loose_files{true};
@@ -51,12 +52,19 @@ struct LaunchOptions {
unsigned menu_key{0x2D}; // VK_INSERT
unsigned console_key{0xC0}; // VK_OEM_3, the key left of 1
std::string log_level{"info"};
// -map <name>: the map the game loads once it is up, named as the console's "load" names
// one (a map's name, short name or asset path, or the start of one). Empty: none.
std::wstring map;
std::vector<std::wstring> game_arguments;
};
// Arguments exclude argv[0]. Saved game display settings apply by default.
// --windowed or an explicit width/height requests the windowed override.
LaunchOptions parse_launch_options(const std::vector<std::wstring>& arguments);
// Takes "-map <name>" (--map, -map=<name>) out of `arguments` and returns the name it gave,
// checked as parse_launch_options checks it; empty when there is none. For a launch whose
// other options come from somewhere else (the launcher's saved settings).
std::wstring take_map_option(std::vector<std::wstring>& arguments);
std::wstring windowed_arguments(const LaunchOptions& options);
// Detect mods beside Skate.exe. Explicit game data paths take precedence.
std::wstring mod_data_arguments(const std::filesystem::path& game_directory,
+4 -2
View File
@@ -40,8 +40,10 @@ inline std::ptrdiff_t peek_cstring(std::uintptr_t address, char* destination, st
if (!destination || !capacity || address < 0x10000 || address > highest_user_address - capacity) return -1;
__try {
const auto* source = reinterpret_cast<const char*>(address);
for (std::size_t i = 0; i < capacity; ++i)
if (!(destination[i] = source[i])) return static_cast<std::ptrdiff_t>(i);
for (std::size_t i = 0; i < capacity; ++i) {
destination[i] = source[i];
if (destination[i] == '\0') return static_cast<std::ptrdiff_t>(i);
}
return -1;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return -1;
+92 -4
View File
@@ -186,6 +186,7 @@ const HANDLE symbol_session = reinterpret_cast<HANDLE>(static_cast<std::uintptr_
bool symbols_ready{};
std::unordered_set<std::uintptr_t> symbols_loaded;
const char waiting_marker{}; // an address inside this module, to tell it from the others
std::string symbol_name(const Module& module, std::uintptr_t address) {
std::lock_guard lock(symbols_mutex);
if (!symbols_ready) {
@@ -200,8 +201,16 @@ std::string symbol_name(const Module& module, std::uintptr_t address) {
info->SizeOfStruct = sizeof(SYMBOL_INFO);
info->MaxNameLen = 511;
DWORD64 displacement{};
if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0])
return module.name + "!" + info->Name;
if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0]) {
// A release ReSkate.dll ships without its symbols, so all this finds in it is the nearest
// export, megabytes away: every function of ours then reads as one of three exports and
// a report cannot say which was slow. Unless the address is inside a symbol with a size
// (the real ones have one), give our own module's address instead: the release's symbols
// (build/release/ReSkate-<version>-symbols) name it afterwards.
const auto here = reinterpret_cast<std::uintptr_t>(&waiting_marker);
const bool own = module.base <= here && here < module.end;
if (!own || (info->Size && displacement < info->Size)) return module.name + "!" + info->Name;
}
return std::format("{}+0x{:x}", module.name, address - module.base);
}
@@ -238,7 +247,7 @@ std::string sanitize(std::string text) {
struct Sampled { HANDLE handle{}; std::uint32_t id{}; std::string label; };
// One sample: where its frames start in the pool, how many, and which thread it came from.
struct Record { std::uint32_t offset{}; std::uint16_t thread{}; std::uint8_t depth{}; };
struct Record { std::uint32_t offset{}; std::uint16_t thread{}; std::uint8_t depth{}; std::uint32_t at_us{}; };
// The pool holds at most this many frames (64 MiB); a sample that no longer fits ends the run.
constexpr std::size_t pool_limit = 8u << 20;
@@ -296,7 +305,8 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
if (GetThreadContext(threads[t].handle, &context)) depth = walk(modules, context, pool.data() + used);
ResumeThread(threads[t].handle);
if (!depth) continue;
records.push_back({static_cast<std::uint32_t>(used), static_cast<std::uint16_t>(t), static_cast<std::uint8_t>(depth)});
records.push_back({static_cast<std::uint32_t>(used), static_cast<std::uint16_t>(t), static_cast<std::uint8_t>(depth),
static_cast<std::uint32_t>(std::chrono::duration_cast<std::chrono::microseconds>(now - start).count())});
used += depth;
}
if (!any && !full) { report.error = "The thread has ended."; break; }
@@ -395,6 +405,83 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
return a.self != b.self ? a.self > b.self : a.total > b.total;
});
// ---- the slowest client frames: what a hitch is made of ----
// An average hides a hitch: one 60 ms frame in thirty seconds is a fifth of a percent of
// the samples. A client frame is a run of samples with the client frame job on the
// stack; the longest few are listed with what they spent their time in, below that job.
std::string hitches;
if (request.target == Target::client && !records.empty()) {
const auto period_us = static_cast<std::uint32_t>(1'000'000 / request.rate);
const auto stack_of = [&](const Record& record, std::vector<std::uintptr_t>& out) {
out.clear();
const auto* sample = pool.data() + record.offset;
for (std::uint32_t f = 0; f < record.depth; ++f) {
const Module* module{};
out.push_back(function_start(modules, f ? sample[f] - 1 : sample[f], &module));
owners[out.back()] = module;
}
};
// Where in a stack the client frame job is (counting from the leaf), or none.
const auto job_at = [&](const std::vector<std::uintptr_t>& frames) -> std::ptrdiff_t {
for (std::size_t f = 0; f < frames.size(); ++f)
if (name_of(frames[f], owners[frames[f]]).find("Client frame job") != std::string::npos)
return static_cast<std::ptrdiff_t>(f);
return -1;
};
struct Frame { std::size_t first{}, last{}; std::uint32_t us{}; };
std::vector<Frame> frames;
std::vector<std::uintptr_t> walked;
bool inside{};
for (std::size_t i = 0; i < records.size(); ++i) {
stack_of(records[i], walked);
const bool in_frame = job_at(walked) >= 0;
// (A gap in the samples ends a frame too: the sampler itself was held up.)
const bool continues = inside && in_frame && records[i].at_us - records[frames.back().last].at_us < period_us * 4;
if (continues) frames.back().last = i;
else if (in_frame) frames.push_back({i, i, 0});
inside = in_frame;
}
for (auto& frame : frames) frame.us = records[frame.last].at_us - records[frame.first].at_us + period_us;
if (frames.size() >= 8) {
std::vector<std::uint32_t> lengths;
for (const auto& frame : frames) lengths.push_back(frame.us);
std::ranges::sort(lengths);
const auto median = lengths[lengths.size() / 2];
std::ranges::sort(frames, [](const Frame& a, const Frame& b) { return a.us > b.us; });
hitches = std::format("Slowest client frames, of {} (the usual one takes {:.1f} ms of this thread). Under each: what it spent\n"
"its time in, as shares of that frame.\n", frames.size(), median / 1000.0);
for (std::size_t n = 0; n < frames.size() && n < 6; ++n) {
const auto& frame = frames[n];
if (frame.us < median * 2 && n) break;
std::unordered_map<std::uintptr_t, Counts> inner;
std::uint32_t samples{};
for (std::size_t i = frame.first; i <= frame.last; ++i) {
stack_of(records[i], walked);
const auto job = job_at(walked);
if (job < 0) continue;
++samples;
for (std::ptrdiff_t f = 0; f < job; ++f) {
auto& counts = inner[walked[static_cast<std::size_t>(f)]];
counts.module = owners[walked[static_cast<std::size_t>(f)]];
if (!f) ++counts.self;
const auto here = walked.begin() + f;
if (std::find(walked.begin(), here, walked[static_cast<std::size_t>(f)]) == here) ++counts.total;
}
}
hitches += std::format("\n {:.1f} ms, {:.2f} s into the sample ({} samples)\n", frame.us / 1000.0,
records[frame.first].at_us / 1e6, samples);
std::vector<std::pair<std::uintptr_t, Counts>> order(inner.begin(), inner.end());
// The work, not the way down to it: what nearly the whole frame 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 < 14; ++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";
}
}
// ---- files next to ReSkate.log ----
const auto directory = logging::status().directory;
SYSTEMTIME now{};
@@ -423,6 +510,7 @@ void run(SampleRequest request, std::vector<Sampled> threads) {
out << "Skate.exe functions are Ghidra addresses (image base 0x140000000). Name them in\n"
"ReSkate.labels.tsv next to Skate.exe (\"<address> <name>\" per line) for the next report.\n"
"Flame graph: open the .folded file at https://www.speedscope.app\n\n";
out << hitches;
out << " Self Total Function\n";
for (const auto& f : report.functions) {
if (f.total * 1000.0 < per) continue; // under 0.1%
@@ -3,6 +3,7 @@
#include <cstdio>
#include <string>
#include <string_view>
#include <vector>
namespace {
int failures = 0;
@@ -23,6 +24,15 @@ void masks(std::string_view text, std::string_view expected) {
} // namespace
int main() {
// The filter has no words of its own: until a list arrives nothing is filtered. These stand
// in for the backend's.
clean("what the fuck");
{
const std::vector<std::string> listed{"fuck", "fucking", "motherfucker", "shit", "ass", "bitch", "rape", "raped",
"raping", "rapist", "gangrape", "dick", "cock", "cunt", "hell", "arse",
"perse", "tit", "cum", "sex", "paki", "crap", "turd", "puta"};
dingosdk::text::set_word_lists(listed, {});
}
// Listed words, any case, whole or inside longer words.
bad("fuck");
bad("FUCK this server");
@@ -40,6 +50,13 @@ int main() {
bad("evil ass rape server");
bad("Rapist Crew");
bad("gangrape lobby");
bad("bullshit");
bad("dumbfucks");
bad("shitty park");
bad("fuckkkk");
bad("xxfuckxx");
bad("Dickhead Crew");
bad("absofuckinglutely");
// Ordinary words that contain a listed word, and server names people use.
clean("Hello World");
clean("Shell Shock Skatepark");
@@ -73,6 +90,43 @@ int main() {
masks("f u c k", "* * * *");
masks("Hello friends", "Hello friends");
masks("fuckface", "****face");
// New lists take the place of the ones before: those words are no longer filtered, the new
// ones are, and the ones not allowed at all are both filtered and told apart.
{
using namespace dingosdk::text;
const auto expect = [&](bool ok, const char* what) {
if (!ok) { std::printf("FAIL: %s\n", what); ++failures; }
};
expect(!contains_forbidden_words("what the fuck"), "a word was forbidden before any list said so");
const std::vector<std::string> filtered{"Darn", "heck"}, forbidden{"Zorblat", "@$$hat"};
set_word_lists(filtered, forbidden);
expect(!contains_bad_words("what the fuck") && contains_bad_words("oh D4RN it") && contains_bad_words("heckin") &&
!contains_bad_words("check"),
"the given filtered words are not the ones filtered");
expect(contains_forbidden_words("you z0rblat") && contains_forbidden_words("megazorblatter") && contains_forbidden_words("a s s h a t") &&
!contains_forbidden_words("darn heck") && !contains_forbidden_words("zorb lat"),
"the words not allowed at all are not matched as the filter matches");
expect(mask_bad_words("darn you zorblat") == "**** you *******", "a forbidden word is not masked like a filtered one");
// Short entries, and fragments of words, as the backend's lists have them: they are in
// many ordinary words, and count only as the word being said.
const std::vector<std::string> fragments{"gger", "igger", "spik", "nega", "negar", "crip", "coon", "coons", "raper",
"nige", "niger", "nicker", "nickers", "hom0", "nigger", "the rapist"};
set_word_lists({}, fragments);
for (const char* ordinary :
{"a bigger spike", "Spikes and spiked rails", "spiky", "trigger", "logger", "swagger", "debugger", "negative",
"vinegar", "renegade", "script", "description", "raccoon", "raccoons", "tycoon", "cocoon", "snicker", "snickers", "knickers", "triggers",
"scraper", "Nigeria", "Nigel", "homosexual", "my therapist", "therapists"})
if (contains_forbidden_words(ordinary)) std::printf("FAIL: ordinary word forbidden: %s\n", ordinary), ++failures;
for (const char* said : {"coon", "c00ns", "spiks", "the spik", "bigcoon", "xxcoonxx", "crips", "niggers", "bniggerz", "sandnigger",
"homo", "homos", "rapers"})
if (!contains_forbidden_words(said)) std::printf("FAIL: not forbidden: %s\n", said), ++failures;
expect(mask_bad_words("bigger coons") == "bigger *****", "a short word is not masked only where it is said");
set_word_lists({}, {});
expect(!contains_bad_words("darn zorblat fuck") && !contains_forbidden_words("zorblat") && mask_bad_words("darn") == "darn",
"empty lists still filter");
reset_word_lists();
expect(!contains_bad_words("what the fuck darn") && !contains_forbidden_words("zorblat"), "words are filtered without a list");
}
if (failures == 0) std::printf("word filter: all checks passed\n");
return failures == 0 ? 0 : 1;
}
-426
View File
@@ -1,426 +0,0 @@
@$$
AssMonkey
Assface
Biatch
BlowJob
CarpetMuncher
Clit
Cock
CockSucker
Ekrem
Ekto
Felcher
Flikker
Fotze
Fu
FudgePacker
Fukah
Fuken
Fukin
Fukk
Fukkah
Fukken
Fukker
Fukkin
Goddamned
Huevon
Kurac
Lesbian
Lezzian
Lipshits
Lipshitz
MothaFucker
MothaFuker
MothaFukkah
MothaFukker
MotherFucker
MotherFukah
MotherFuker
MotherFukkah
MotherFukker
MuthaFucker
MuthaFukah
MuthaFuker
MuthaFukkah
MuthaFukker
Phuc
Phuck
Phuk
Phuker
Phukker
Poonani
Shitty
Shity
Sht
Shyt
Shyte
Shytty
Skanky
Slutty
ahole
amcik
andskota
anus
arschloch
arse
ash0le
ash0les
asholes
ass
assh0le
assh0lez
asshole
assholes
assholz
assrammer
asswipe
ayir
azzhole
b00bs
b17ch
b1tch
bassterds
bastard
bastards
bastardz
basterds
basterdz
bch
bi7ch
bich
bitch
bitches
blowjob
boffing
boiolas
bollock
boobs
breasts
btch
buceta
bullshit
butthole
buttpirate
buttwipe
c0ck
c0cks
c0k
cabron
cawk
cawks
cazzo
chink
chraa
chuj
cipa
clit
clits
cnts
cntz
cock
cockhead
cocks
cocksucker
crap
cum
cunt
cunts
cuntz
d4mn
damn
daygo
dego
dick
dike
dild0
dild0s
dildo
dildos
dilld0
dilld0s
dirsa
dominatricks
dominatrics
dominatrix
dupa
dyke
dziwka
ejackulate
ejakulate
enculer
enema
faen
fag
fag1t
faget
fagg1t
faggit
faggot
fagit
fags
fagz
faig
faigs
fanculo
fanny
fart
fatass
fcuk
feces
feg
ficken
fitt
flipping
foreskin
fuchah
fuck
fucka
fucker
fuckin
fucking
fucks
fuk
fukah
fuker
fukka
fukkah
fukker
futkretzn
fux0r
g00k
gay
gaybor
gayboy
gaygirl
gays
gayz
gangrape
gangraped
gook
guiena
h00r
h0ar
h0r
h0re
h4x0r
hell
hells
helvete
hoar
hoer
honkey
hoor
hoore
hore
hui
injun
jackoff
jap
japs
jerkoff
jisim
jism
jiss
jizm
jizz
kanker
kawk
kike
klootzak
knob
knobs
knobz
knulle
kraut
kuk
kuksuger
kunt
kunts
kuntz
kurwa
kusi
kyrpa
l3i+ch
l3itch
lesbian
lesbo
mamhoon
masochist
masokist
massterbait
masstrbait
masstrbate
masterbaiter
masterbat
masterbat3
masterbate
masterbates
masturbat
masturbate
merd
mibun
mofo
monkleigh
motha
motherfucker
mouliewop
muie
mulkku
muschi
mutha
n1gr
nastt
nasty
nazi
nazis
nepesaurio
nigga
niggas
nigger
nigur
niiger
niigr
nutsack
orafis
orgasim
orgasm
orgasum
oriface
orifice
orifiss
orospu
p0rn
packi
packie
packy
paki
pakie
paky
paska
pecker
peeenus
peeenusss
peenus
peinus
pen1s
penas
penis
penisbreath
penus
penuus
perse
phuck
picka
pierdol
pillu
pimmel
pimpis
piss
pizda
polac
polack
polak
poontsee
poop
porn
pr0n
pr1c
pr1ck
pr1k
preteen
pula
pule
pusse
pussee
pussy
puta
puto
puuke
puuker
qahbeh
queef
queer
queers
queerz
qweers
qweerz
qweir
rape
raped
rapes
raping
rapist
rapists
rautenberg
recktum
rectum
retard
s.o.b.
sadist
scank
schaffer
scheiss
schlampe
schlong
schmuck
screw
screwing
scrotum
semen
sex
sexx
sexxx
sexy
sh1t
sh1ter
sh1ts
sh1tter
sh1tz
sharmuta
sharmute
shemale
shi+
shipal
shit
shits
shitt
shitter
shitz
shiz
skanck
skank
skankee
skankey
skanks
skrib
slut
sluts
slutz
smut
sonofabitch
sx
teets
teez
testical
testicle
tit
tits
titt
turd
va1jina
vag1na
vagiina
vagina
vaj1na
vajina
vullva
vulva
w00se
w0p
wank
wh00r
wh0re
whoar
whore
xrated
xxx
+139 -30
View File
@@ -1,11 +1,14 @@
#include "word_filter.h"
#include "embedded_bad_words.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstddef>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <unordered_set>
#include <vector>
@@ -45,13 +48,38 @@ constexpr std::string_view whole_word_only[]{
"rapist", "rapists", "rautenberg", "schaffer", "screw", "screwing", "semen", "shiz", "smut", "teets",
"teez", "tits", "titt", "turd", "woose"};
// Ordinary words with a bad word inside; a match that one of these covers does not count.
// Ordinary words with a bad word inside; a match that one of these covers does not count. One
// that is a listed entry with its spaces taken out ("the rapist") does not count either.
constexpr std::string_view allowed_words[]{
"scunthorpe", "dickens", "dickinson", "dickson", "dickies", "cocktail", "cockpit", "peacock",
"cockroach", "cockatoo", "cockatiel", "cockney", "cockle", "cockerel", "cockburn", "cocky",
"hancock", "hitchcock", "woodcock", "babcock", "shuttlecock", "ashkenazi", "shiitake", "shitake",
"matsushita", "yamashita", "kinoshita", "morishita", "takeshita", "swank", "swanky", "woodpecker",
"clitheroe", "snigger", "pussycat", "pissarro", "retardant"};
"clitheroe", "snigger", "pussycat", "pissarro", "retardant", "cocker", "spike", "spiky",
"spiking", "snicker", "snickers", "knicker", "knickers", "triggers", "diggers", "riggers",
"therapist", "specialist", "cumin", "christmas", "christian",
"christopher", "christina", "christine"};
// A listed word this long is not found inside an ordinary word by accident ("nigger",
// "faggot"; allowed_words has the few exceptions), so it counts anywhere in a longer one. A
// shorter one ("fuck", "shit", but also a fragment like "spik" or "coon") is inside too many
// ordinary words: it counts only when the rest of the word is what people put around a swear
// word, so "motherfuckers", "shithead" and "bullshit" do, and "spike", "raccoon", "script" and
// "bigger" do not.
constexpr std::size_t counts_anywhere = 6;
// What may come before a short listed word, and after it.
constexpr std::string_view before_words[]{
"a", "an", "the", "my", "ur", "your", "you", "u", "i", "im", "yo", "no",
"big", "lil", "little", "fat", "dumb", "old", "hot", "mega", "super", "ultra", "hella", "total",
"anti", "un", "non", "pro", "ex", "mc", "mr", "sir", "king", "lord", "god", "mother",
"mutha", "motha", "bull", "horse", "dog", "ape", "bat", "dip", "jack", "chicken", "cluster", "mind",
"brain", "butt", "pig", "skull", "stupid", "ugly", "dirty", "lazy", "crazy"};
constexpr std::string_view after_words[]{
"s", "es", "z", "ed", "er", "ers", "erz", "ing", "ings", "in", "y", "ie",
"ies", "ier", "ish", "a", "ah", "as", "az", "o", "os", "ism", "ist", "ists",
"head", "heads", "face", "faces", "hole", "holes", "wad", "tard", "bag", "boy", "boi", "girl",
"man", "stick", "wit", "wits", "lord", "king", "nut", "less", "ness", "ery", "fest", "show",
"storm", "load", "stain", "off", "up", "it", "me", "you", "u", "ya"};
struct Hash {
using is_transparent = void;
@@ -65,28 +93,59 @@ struct Lists {
std::size_t shortest_inside{~std::size_t{}}, longest_inside{};
};
const Lists& lists() {
static const Lists built = [] {
Lists l;
std::string_view text = embedded::bad_words_list;
while (!text.empty()) {
const auto end = text.find('\n');
const auto line = text.substr(0, end);
text = end == std::string_view::npos ? std::string_view{} : text.substr(end + 1);
std::string word;
for (const char c : line)
if (const char letter = fold(c)) word += letter;
if (word.empty()) continue;
l.whole.insert(word);
if (word.size() > 3 && std::ranges::find(whole_word_only, word) == std::ranges::end(whole_word_only)) {
l.shortest_inside = std::min(l.shortest_inside, word.size());
l.longest_inside = std::max(l.longest_inside, word.size());
l.inside.insert(std::move(word));
}
}
return l;
}();
return built;
std::string folded(std::string_view line) {
std::string word;
for (const char c : line)
if (const char letter = fold(c)) word += letter;
return word;
}
void add(Lists& l, std::string_view line) {
auto word = folded(line);
if (word.empty()) return;
l.whole.insert(word);
if (word.size() > 3 && std::ranges::find(whole_word_only, word) == std::ranges::end(whole_word_only)) {
l.shortest_inside = std::min(l.shortest_inside, word.size());
l.longest_inside = std::max(l.longest_inside, word.size());
l.inside.insert(std::move(word));
}
}
// What is filtered (the forbidden words too), and what is forbidden outright.
struct Rules {
Lists filtered, forbidden;
};
// Until a list is given there are no words: nothing is filtered.
std::shared_ptr<const Rules> no_words() {
static const std::shared_ptr<const Rules> rules = std::make_shared<Rules>();
return rules;
}
// What the rules in use are made from: the backend's lists once they arrive, and a server's own
// words. Changed under the mutex; readers take the rules made from them.
struct Sources {
std::optional<std::pair<std::vector<std::string>, std::vector<std::string>>> given;
std::vector<std::string> own_filtered, own_forbidden;
bool use_given = true;
};
std::mutex sources_mutex;
Sources sources;
std::atomic<std::shared_ptr<const Rules>> made;
std::shared_ptr<const Rules> rules() {
auto current = made.load();
return current ? current : no_words();
}
void remake() {
const bool backend = sources.given && sources.use_given;
if (!backend && sources.own_filtered.empty() && sources.own_forbidden.empty()) return made.store({});
auto r = std::make_shared<Rules>();
if (backend) {
for (const auto& word : sources.given->first) add(r->filtered, word);
for (const auto& word : sources.given->second) add(r->filtered, word), add(r->forbidden, word);
}
for (const auto& word : sources.own_filtered) add(r->filtered, word);
for (const auto& word : sources.own_forbidden) add(r->filtered, word), add(r->forbidden, word);
made.store(std::shared_ptr<const Rules>(std::move(r)));
}
// A word as folded letters, with where each letter sits in the text.
@@ -134,18 +193,37 @@ bool covered(std::string_view letters, std::size_t start, std::size_t length) {
return false;
}
// Letters added only to get a word past the filter: "xx", "zz".
bool padding(std::string_view letters) {
return std::ranges::all_of(letters, [](char c) { return c == 'x' || c == 'z'; });
}
// Whether a short listed word at letters[start, start + length) is the word being said, going by
// what is around it: nothing, another listed word, or one of the usual beginnings and endings.
// A last letter said again counts as part of it ("shitty", "fuckkk").
bool said(const Lists& l, std::string_view letters, std::size_t start, std::size_t length) {
const auto before = letters.substr(0, start);
auto after = letters.substr(start + length);
while (!after.empty() && after.front() == letters[start + length - 1]) after.remove_prefix(1);
const auto fits = [&](std::string_view part, std::span<const std::string_view> usual) {
return part.empty() || padding(part) || l.whole.contains(part) || std::ranges::find(usual, part) != usual.end();
};
return fits(before, before_words) && fits(after, after_words);
}
// Whether `word` has a bad word in it; with `marked`, flags each letter that belongs to one.
bool match(const Word& word, std::vector<bool>* marked) {
const auto& l = lists();
bool match(const Lists& l, const Word& word, std::vector<bool>* marked) {
const std::string_view letters = word.letters;
if (l.whole.contains(letters)) {
if (std::ranges::find(allowed_words, letters) != std::ranges::end(allowed_words)) return false;
if (marked) marked->assign(letters.size(), true);
return true;
}
bool found{};
if (l.inside.empty()) return false;
for (std::size_t start = 0; start + l.shortest_inside <= letters.size(); ++start)
for (auto length = l.shortest_inside; length <= l.longest_inside && start + length <= letters.size(); ++length) {
if (!l.inside.contains(letters.substr(start, length)) || covered(letters, start, length)) continue;
if (length < counts_anywhere && !said(l, letters, start, length)) continue;
if (!marked) return true;
found = true;
std::fill(marked->begin() + static_cast<std::ptrdiff_t>(start),
@@ -156,16 +234,47 @@ bool match(const Word& word, std::vector<bool>* marked) {
} // namespace
bool contains_bad_words(std::string_view text) {
const auto r = rules();
for (const auto& word : words(text))
if (match(word, nullptr)) return true;
if (match(r->filtered, word, nullptr)) return true;
return false;
}
bool contains_forbidden_words(std::string_view text) {
const auto r = rules();
if (r->forbidden.whole.empty()) return false;
for (const auto& word : words(text))
if (match(r->forbidden, word, nullptr)) return true;
return false;
}
void set_word_lists(std::span<const std::string> filtered, std::span<const std::string> forbidden) {
std::lock_guard lock(sources_mutex);
sources.given.emplace(std::vector<std::string>(filtered.begin(), filtered.end()),
std::vector<std::string>(forbidden.begin(), forbidden.end()));
remake();
}
void reset_word_lists() {
std::lock_guard lock(sources_mutex);
sources.given.reset();
remake();
}
void set_own_words(std::span<const std::string> filtered, std::span<const std::string> forbidden, bool use_given) {
std::lock_guard lock(sources_mutex);
sources.own_filtered.assign(filtered.begin(), filtered.end());
sources.own_forbidden.assign(forbidden.begin(), forbidden.end());
sources.use_given = use_given;
remake();
}
std::string mask_bad_words(std::string_view text) {
const auto r = rules();
std::string out(text);
for (const auto& word : words(text)) {
std::vector<bool> marked(word.letters.size());
if (!match(word, &marked)) continue;
if (!match(r->filtered, word, &marked)) continue;
for (std::size_t i = 0; i < marked.size(); ++i)
if (marked[i]) out[word.at[i]] = '*';
}
+25 -5
View File
@@ -1,19 +1,39 @@
#pragma once
#include <span>
#include <string>
#include <string_view>
// The bad-word filter: the list in bad_words.txt (built in), matched against text after
// folding case and look-alike characters, so "Sh1t", "@$$" and "f u c k" count too.
// The bad-word filter: a list of words matched against text after folding case and look-alike
// characters, so "Sh1t", "@$$" and "f u c k" count too. The words are the ReSkate backend's
// (set_word_lists), in two lists: the filtered words, and the ones not allowed in chat at all.
// Both are filtered. Until they arrive there are none, and nothing is filtered.
//
// - Words of three letters or fewer, and a few longer ones that are common inside ordinary
// words ("hell" in hello, "arse" in parse), only match a whole word.
// - Every other word also matches inside a longer one ("fuckface"), unless an ordinary word
// covers it there ("Scunthorpe", "cocktail").
// - A word of six letters or more also matches anywhere inside a longer one, unless an ordinary
// word covers it there ("snigger", "retardant").
// - A word of four or five letters matches inside a longer one only when the rest of it is what
// goes around a swear word ("fuckface", "motherfuckers", "bullshit"), so not in "spike",
// "raccoon" or "script".
//
// Used for dedicated server names (the server refuses to list one, the client hides it) and,
// as a player option, to mask chat.
// as a player option, to mask chat. A dedicated server and a lobby's host do not pass on a chat
// message with a word that is not allowed at all, and warn and then kick who said it.
namespace dingosdk::text {
bool contains_bad_words(std::string_view text);
// One of the words not allowed at all. There are none until set_word_lists gives some.
bool contains_forbidden_words(std::string_view text);
// The lists in use from now on (any thread).
void set_word_lists(std::span<const std::string> filtered, std::span<const std::string> forbidden);
// Back to no lists: nothing filtered or forbidden but a server's own words.
void reset_word_lists();
// A dedicated server's own words, on top of the lists above: more filtered words, and more
// that are not allowed at all (Server/server_words.h). The server sends its filtered ones to
// its players (Packet words), whose games give them here while they are on it, and none again
// when they leave.
// - `use_given` false (a server's "use_global_word_filter") leaves the backend's lists out:
// only the server's own words are filtered and forbidden.
void set_own_words(std::span<const std::string> filtered, std::span<const std::string> forbidden, bool use_given = true);
// `text` with each letter of every bad word replaced by '*'; same length, other text untouched.
std::string mask_bad_words(std::string_view text);
@@ -0,0 +1,45 @@
// LinearTransform math: points placed, directions turned, transforms composed.
#include "Engine/Game/Abi/linear_transform.h"
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace dingosdk::game;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
bool near(float a, float b) { return std::abs(a - b) < 1e-4f; }
void a_point_is_placed_in_its_transform() {
// Turned 90 degrees about +Z (x -> y), standing at (1, 2, 3).
const LinearTransform transform{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {1, 2, 3, 1}}};
const auto point = place({2, 0, 1}, transform);
check(near(point[0], 1) && near(point[1], 4) && near(point[2], 4), "offsets turn with the transform and move with it");
const auto direction = turn({2, 0, 1}, transform);
check(near(direction[0], 0) && near(direction[1], 2) && near(direction[2], 1), "a direction only turns");
}
void transforms_compose_inner_first() {
const LinearTransform outer{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {1, 2, 3, 1}}};
const LinearTransform inner{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 5, 1}}}; // 5 along z
const auto both = compose(inner, outer);
const auto through = place(place({2, 0, 1}, inner), outer), at_once = place({2, 0, 1}, both);
for (std::size_t i = 0; i < 3; ++i) check(near(through[i], at_once[i]), "one transform does what both do");
}
void lengths() {
check(near(length({3, 4, 0}), 5.0f) && length({}) == 0.0f && near(length({0.1f, 0, 0}), 0.1f), "lengths");
}
}
int main() {
try {
a_point_is_placed_in_its_transform();
transforms_compose_inner_first();
lengths();
} catch (const std::exception& error) {
std::cerr << "FAILED: " << error.what() << '\n';
return 1;
}
std::cout << "Linear transform tests passed.\n";
return 0;
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <array>
#include <cmath>
#include <cstddef>
// Frostbite's LinearTransform as the game keeps it in memory: the right, up and forward rows,
// then the translation, four floats each (the fourth lanes are not the matrix's and are never
// read). Points are row vectors: a point given in a transform lands at point x transform.
namespace dingosdk::game {
using Vec3 = std::array<float, 3>;
using LinearTransform = std::array<std::array<float, 4>, 4>;
inline float length(const Vec3& v) noexcept { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); }
// Where a point given in a transform lands.
constexpr Vec3 place(const Vec3& point, const LinearTransform& transform) noexcept {
Vec3 result{};
for (std::size_t i = 0; i < 3; ++i)
result[i] = point[0] * transform[0][i] + point[1] * transform[1][i] + point[2] * transform[2][i] + transform[3][i];
return result;
}
// Where a direction given in a transform points: turned, not moved.
constexpr Vec3 turn(const Vec3& direction, const LinearTransform& transform) noexcept {
Vec3 result{};
for (std::size_t i = 0; i < 3; ++i)
result[i] = direction[0] * transform[0][i] + direction[1] * transform[1][i] + direction[2] * transform[2][i];
return result;
}
// `inner` given in `outer`: the transform a point of `inner` lands in through both.
constexpr LinearTransform compose(const LinearTransform& inner, const LinearTransform& outer) noexcept {
LinearTransform result{};
for (std::size_t row = 0; row < 3; ++row) {
const auto axis = turn({inner[row][0], inner[row][1], inner[row][2]}, outer);
result[row] = {axis[0], axis[1], axis[2], 0.0f};
}
const auto position = place({inner[3][0], inner[3][1], inner[3][2]}, outer);
result[3] = {position[0], position[1], position[2], 1.0f};
return result;
}
}
+305 -301
View File
@@ -8,8 +8,11 @@ namespace dingosdk::game::build::v20260929::entity_pages {
// EntityBus TransformSpace and property-space snapshots have 128 page pointers.
// Slot 131072 formerly indexed pointer 128, overwriting snapshot bookkeeping.
// Enlarge the leaf pages without changing owners, snapshots, IDs or their ABI.
// Four times the game's 131,072 slots (it was eight until 2026-10-10): the largest map load
// seen needed 150,152, and every page set the game keeps is this many times its own size,
// whether a map uses it or not.
inline constexpr std::size_t page_count = 128;
inline constexpr unsigned page_shift = 13;
inline constexpr unsigned page_shift = 12;
inline constexpr std::size_t page_entries = std::size_t{1} << page_shift;
inline constexpr std::size_t page_bytes = page_entries * sizeof(std::uint64_t);
inline constexpr std::size_t capacity = page_count * page_entries;
@@ -17,13 +20,14 @@ inline constexpr std::size_t capacity = page_count * page_entries;
// object addresses. Grow its 464-byte-record pages without changing that ABI.
inline constexpr unsigned render_instance_page_shift = 11;
inline constexpr std::size_t render_instance_page_bytes = (std::size_t{1} << render_instance_page_shift) * 0x1d0;
static_assert((512u << render_instance_page_shift) == capacity);
// (Left at eight times the game's: its pages are its own, by its own shift.)
static_assert((512u << render_instance_page_shift) >= capacity);
static_assert(render_instance_page_bytes == 0xe8000);
// The cull-ID list and this separate monotonic bounds arena must both fit the
// city. Retail config can override GridSize after Game.cfg has been processed.
// Preserve native allocation/reset ownership and any larger configured value.
inline constexpr std::size_t spatial_arena_min_bytes = 128 * 1024 * 1024;
static_assert(capacity == 1'048'576 && page_bytes == 65'536);
static_assert(capacity == 524'288 && page_bytes == 32'768);
enum class Kind {
index_shift, page_bytes, page_entries, spatial_arena_bytes,
@@ -67,303 +71,303 @@ inline constexpr Patch patches[]{
patch(0x050e244d, Kind::render_instance_pages, "41b800a00300", "41b800800e00"),
patch(0x050e245b, Kind::render_instance_pages, "c1e309", "c1e30b"),
patch(0x0489ee29, Kind::spatial_arena_bytes, "bb00001000", "bb00000008"),
patch(0x048a5e2c, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048a5e31, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048a5fec, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048a6015, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048b2713, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048b271c, Kind::index_shift, "41c1e00a", "41c1e00d"),
patch(0x048b27b6, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b27c4, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b292a, Kind::index_shift, "41c1ea0a", "41c1ea0d"),
patch(0x048b2935, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b3816, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048b3823, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048b5112, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x048b5122, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x048b51a2, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x048b51b2, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x048b52d6, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b52dd, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b53cc, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b53d3, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b5692, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x048b56a2, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x048b57a9, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b57b0, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b58b6, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b58bd, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048b7ed4, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048b7ef5, Kind::index_shift, "41c1e00a", "41c1e00d"),
patch(0x048b8530, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048b8556, Kind::index_shift, "41c1e20a", "41c1e20d"),
patch(0x048b9879, Kind::page_entries, "81c600040000", "81c600200000"),
patch(0x048b9881, Kind::page_bytes, "488daf00200000", "488daf00000100"),
patch(0x048b99d8, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x048b99e4, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x048bb417, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048bb41f, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048bb76a, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bb771, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bb83a, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bb841, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bb8fa, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bb901, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bb9b9, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bb9c0, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bba89, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bba90, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bbb89, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bbb90, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bbc4b, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bbc52, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bbd39, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bbd40, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bbf26, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bbf2d, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bd05e, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x048bd06d, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048bd42b, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bd432, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048bd516, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048bd51d, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048c019e, Kind::page_entries, "4181c600040000", "4181c600200000"),
patch(0x048c01a7, Kind::page_bytes, "4c8da500200000", "4c8da500000100"),
patch(0x048c024e, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x048c025a, Kind::page_bytes, "4c8dad00200000", "4c8dad00000100"),
patch(0x048c069c, Kind::page_entries, "81c500040000", "81c500200000"),
patch(0x048c06a4, Kind::page_bytes, "4c8db600200000", "4c8db600000100"),
patch(0x048c07f8, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x048c0804, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x048c3942, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x048c394f, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c39bb, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048c39c0, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048c4fbd, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048c4fc5, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048c50cf, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x048c50d6, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048c5d01, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x048c5d06, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x048c5e1c, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5e53, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5e8d, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5ec7, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5f01, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5f3b, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5f75, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c5faf, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x048c703c, Kind::index_shift, "c1ea0a", "c1ea0d"),
patch(0x048c7049, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ea3670, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ea3692, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ea3b1d, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ea3b42, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04ea3bd0, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ea3bf8, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04ec12e3, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ec12eb, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ec13c7, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ec13d5, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04ec2b13, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ec2b1b, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ec2bf7, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ec2c05, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04ec2dc6, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ec2dce, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ec2eb7, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ec2ec5, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04ed56e9, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ed56ef, Kind::index_shift, "41c1e20a", "41c1e20d"),
patch(0x04ed7296, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ed72bb, Kind::index_shift, "41c1e20a", "41c1e20d"),
patch(0x04ed78d3, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ed78f8, Kind::index_shift, "41c1e20a", "41c1e20d"),
patch(0x04edde6d, Kind::page_entries, "81c500040000", "81c500200000"),
patch(0x04edde78, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00000100"),
patch(0x04eddff8, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x04ede004, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x04edf33d, Kind::page_entries, "81c500040000", "81c500200000"),
patch(0x04edf348, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00000100"),
patch(0x04edf508, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x04edf514, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x04edf719, Kind::page_entries, "81c600040000", "81c600200000"),
patch(0x04edf724, Kind::page_bytes, "488daf00200000", "488daf00000100"),
patch(0x04edf888, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x04edf894, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x04ee3b47, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ee3b4f, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ee4c77, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ee4c7f, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ee4f47, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ee4f4f, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04ee7daa, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04ee7db9, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04eeb19e, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x04eeb1ad, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04eeba26, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eeba2d, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eec74e, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eec755, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eec8ac, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eec8b3, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eecd45, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eecd58, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eece4d, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04eece6e, Kind::index_shift, "41c1e00a", "41c1e00d"),
patch(0x04eecf6d, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eecf74, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed0a0, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed0a7, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed221, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed228, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed31c, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed323, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed468, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04eed474, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed6a0, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed6a7, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed869, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed870, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eed9f4, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eed9fb, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eee208, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee21b, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee292, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee2a5, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee328, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee33b, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee3b2, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee3c5, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee432, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee445, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee4a2, Kind::index_shift, "41c1e80a", "41c1e80d"),
patch(0x04eee4b5, Kind::index_shift, "c1e00a", "c1e00d"),
patch(0x04eee60a, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04eee62b, Kind::index_shift, "41c1e00a", "41c1e00d"),
patch(0x04eee72f, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eee736, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eee89f, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eee8a6, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eeea0a, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eeea11, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eeecbe, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eeecc5, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eeede9, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eeedf0, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eef06f, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eef076, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eef15d, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eef164, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04eef214, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04eef21b, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04ef0113, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04ef011a, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04ef5deb, Kind::page_entries, "4181c700040000", "4181c700200000"),
patch(0x04ef5df7, Kind::page_bytes, "498db600200000", "498db600000100"),
patch(0x04ef5f89, Kind::page_entries, "4181c700040000", "4181c700200000"),
patch(0x04ef5f95, Kind::page_bytes, "498db600200000", "498db600000100"),
patch(0x04ef60ae, Kind::page_entries, "4181c500040000", "4181c500200000"),
patch(0x04ef60ba, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00000100"),
patch(0x04efc57b, Kind::page_entries, "4181c700040000", "4181c700200000"),
patch(0x04efc587, Kind::page_bytes, "498db600200000", "498db600000100"),
patch(0x04efc729, Kind::page_entries, "4181c700040000", "4181c700200000"),
patch(0x04efc735, Kind::page_bytes, "498db600200000", "498db600000100"),
patch(0x04efc858, Kind::page_entries, "4181c500040000", "4181c500200000"),
patch(0x04efc864, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00000100"),
patch(0x04f097dc, Kind::page_entries, "4181c600040000", "4181c600200000"),
patch(0x04f097e5, Kind::page_bytes, "4c8da500200000", "4c8da500000100"),
patch(0x04f0988f, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x04f0989b, Kind::page_bytes, "4c8dad00200000", "4c8dad00000100"),
patch(0x04f0b69c, Kind::page_entries, "81c500040000", "81c500200000"),
patch(0x04f0b6a7, Kind::page_bytes, "4c8db600200000", "4c8db600000100"),
patch(0x04f0b828, Kind::page_entries, "4181c400040000", "4181c400200000"),
patch(0x04f0b834, Kind::page_bytes, "498dbe00200000", "498dbe00000100"),
patch(0x04f16e2d, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f16e34, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f16ee4, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f16eeb, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f1700c, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f17013, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f172d4, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f172db, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f17618, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f1761f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f19462, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x04f1946f, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f194db, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f194e0, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f19ca2, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x04f19caf, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f19d1b, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f19d20, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f19f22, Kind::page_bytes, "b900200000", "b900000100"),
patch(0x04f19f2f, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f19f9b, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f19fa0, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f1e856, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f1e863, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f1e8f0, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f1e8f8, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f1ea31, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f1ea39, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f216fd, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f2170a, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f21796, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f2179e, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f218d6, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f218de, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f21f09, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f21f16, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f21fa2, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f21faa, Kind::index_shift, "c1e20a", "c1e20d"),
patch(0x04f220e8, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f220f0, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2b166, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f2b16b, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2b2e4, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f2b2ec, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2b38c, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b3c3, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b3fd, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b437, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b471, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b4ab, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b4e5, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2b51f, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2e6e6, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f2e6eb, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2e877, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f2e87f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2e92c, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2e963, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2e99d, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2e9d7, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ea11, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ea4b, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ea85, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2eabf, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ec86, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f2ec8b, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2ee27, Kind::index_shift, "c1e90a", "c1e90d"),
patch(0x04f2ee2f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f2eedc, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ef13, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ef4d, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2ef87, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2efc1, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2effb, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2f035, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f2f06f, Kind::page_bytes, "41b800200000", "41b800000100"),
patch(0x04f34fea, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f34ff1, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f35b60, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f35b66, Kind::index_shift, "41c1e00a", "41c1e00d"),
patch(0x04f38418, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f3841f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f38958, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f3895f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x04f38a78, Kind::index_shift, "c1e80a", "c1e80d"),
patch(0x04f38a7f, Kind::index_shift, "c1e10a", "c1e10d"),
patch(0x048a5e2c, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048a5e31, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048a5fec, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048a6015, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048b2713, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048b271c, Kind::index_shift, "41c1e00a", "41c1e00c"),
patch(0x048b27b6, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b27c4, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b292a, Kind::index_shift, "41c1ea0a", "41c1ea0c"),
patch(0x048b2935, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b3816, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048b3823, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048b5112, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x048b5122, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x048b51a2, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x048b51b2, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x048b52d6, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b52dd, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b53cc, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b53d3, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b5692, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x048b56a2, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x048b57a9, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b57b0, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b58b6, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b58bd, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048b7ed4, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048b7ef5, Kind::index_shift, "41c1e00a", "41c1e00c"),
patch(0x048b8530, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048b8556, Kind::index_shift, "41c1e20a", "41c1e20c"),
patch(0x048b9879, Kind::page_entries, "81c600040000", "81c600100000"),
patch(0x048b9881, Kind::page_bytes, "488daf00200000", "488daf00800000"),
patch(0x048b99d8, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x048b99e4, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x048bb417, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048bb41f, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048bb76a, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bb771, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bb83a, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bb841, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bb8fa, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bb901, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bb9b9, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bb9c0, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bba89, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bba90, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bbb89, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bbb90, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bbc4b, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bbc52, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bbd39, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bbd40, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bbf26, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bbf2d, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bd05e, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x048bd06d, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048bd42b, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bd432, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048bd516, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048bd51d, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048c019e, Kind::page_entries, "4181c600040000", "4181c600100000"),
patch(0x048c01a7, Kind::page_bytes, "4c8da500200000", "4c8da500800000"),
patch(0x048c024e, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x048c025a, Kind::page_bytes, "4c8dad00200000", "4c8dad00800000"),
patch(0x048c069c, Kind::page_entries, "81c500040000", "81c500100000"),
patch(0x048c06a4, Kind::page_bytes, "4c8db600200000", "4c8db600800000"),
patch(0x048c07f8, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x048c0804, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x048c3942, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x048c394f, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c39bb, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048c39c0, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048c4fbd, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048c4fc5, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048c50cf, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x048c50d6, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x048c5d01, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x048c5d06, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x048c5e1c, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5e53, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5e8d, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5ec7, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5f01, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5f3b, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5f75, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c5faf, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x048c703c, Kind::index_shift, "c1ea0a", "c1ea0c"),
patch(0x048c7049, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ea3670, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ea3692, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ea3b1d, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ea3b42, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04ea3bd0, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ea3bf8, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04ec12e3, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ec12eb, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ec13c7, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ec13d5, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04ec2b13, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ec2b1b, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ec2bf7, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ec2c05, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04ec2dc6, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ec2dce, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ec2eb7, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ec2ec5, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04ed56e9, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ed56ef, Kind::index_shift, "41c1e20a", "41c1e20c"),
patch(0x04ed7296, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ed72bb, Kind::index_shift, "41c1e20a", "41c1e20c"),
patch(0x04ed78d3, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ed78f8, Kind::index_shift, "41c1e20a", "41c1e20c"),
patch(0x04edde6d, Kind::page_entries, "81c500040000", "81c500100000"),
patch(0x04edde78, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00800000"),
patch(0x04eddff8, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x04ede004, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x04edf33d, Kind::page_entries, "81c500040000", "81c500100000"),
patch(0x04edf348, Kind::page_bytes, "4c8dbf00200000", "4c8dbf00800000"),
patch(0x04edf508, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x04edf514, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x04edf719, Kind::page_entries, "81c600040000", "81c600100000"),
patch(0x04edf724, Kind::page_bytes, "488daf00200000", "488daf00800000"),
patch(0x04edf888, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x04edf894, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x04ee3b47, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ee3b4f, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ee4c77, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ee4c7f, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ee4f47, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ee4f4f, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04ee7daa, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04ee7db9, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04eeb19e, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x04eeb1ad, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04eeba26, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eeba2d, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eec74e, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eec755, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eec8ac, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eec8b3, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eecd45, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eecd58, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eece4d, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04eece6e, Kind::index_shift, "41c1e00a", "41c1e00c"),
patch(0x04eecf6d, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eecf74, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed0a0, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed0a7, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed221, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed228, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed31c, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed323, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed468, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04eed474, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed6a0, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed6a7, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed869, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed870, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eed9f4, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eed9fb, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eee208, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee21b, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee292, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee2a5, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee328, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee33b, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee3b2, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee3c5, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee432, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee445, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee4a2, Kind::index_shift, "41c1e80a", "41c1e80c"),
patch(0x04eee4b5, Kind::index_shift, "c1e00a", "c1e00c"),
patch(0x04eee60a, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04eee62b, Kind::index_shift, "41c1e00a", "41c1e00c"),
patch(0x04eee72f, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eee736, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eee89f, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eee8a6, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eeea0a, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eeea11, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eeecbe, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eeecc5, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eeede9, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eeedf0, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eef06f, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eef076, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eef15d, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eef164, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04eef214, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04eef21b, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04ef0113, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04ef011a, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04ef5deb, Kind::page_entries, "4181c700040000", "4181c700100000"),
patch(0x04ef5df7, Kind::page_bytes, "498db600200000", "498db600800000"),
patch(0x04ef5f89, Kind::page_entries, "4181c700040000", "4181c700100000"),
patch(0x04ef5f95, Kind::page_bytes, "498db600200000", "498db600800000"),
patch(0x04ef60ae, Kind::page_entries, "4181c500040000", "4181c500100000"),
patch(0x04ef60ba, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00800000"),
patch(0x04efc57b, Kind::page_entries, "4181c700040000", "4181c700100000"),
patch(0x04efc587, Kind::page_bytes, "498db600200000", "498db600800000"),
patch(0x04efc729, Kind::page_entries, "4181c700040000", "4181c700100000"),
patch(0x04efc735, Kind::page_bytes, "498db600200000", "498db600800000"),
patch(0x04efc858, Kind::page_entries, "4181c500040000", "4181c500100000"),
patch(0x04efc864, Kind::page_bytes, "4d8dbe00200000", "4d8dbe00800000"),
patch(0x04f097dc, Kind::page_entries, "4181c600040000", "4181c600100000"),
patch(0x04f097e5, Kind::page_bytes, "4c8da500200000", "4c8da500800000"),
patch(0x04f0988f, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x04f0989b, Kind::page_bytes, "4c8dad00200000", "4c8dad00800000"),
patch(0x04f0b69c, Kind::page_entries, "81c500040000", "81c500100000"),
patch(0x04f0b6a7, Kind::page_bytes, "4c8db600200000", "4c8db600800000"),
patch(0x04f0b828, Kind::page_entries, "4181c400040000", "4181c400100000"),
patch(0x04f0b834, Kind::page_bytes, "498dbe00200000", "498dbe00800000"),
patch(0x04f16e2d, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f16e34, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f16ee4, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f16eeb, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f1700c, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f17013, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f172d4, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f172db, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f17618, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f1761f, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f19462, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x04f1946f, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f194db, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f194e0, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f19ca2, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x04f19caf, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f19d1b, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f19d20, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f19f22, Kind::page_bytes, "b900200000", "b900800000"),
patch(0x04f19f2f, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f19f9b, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f19fa0, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f1e856, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f1e863, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f1e8f0, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f1e8f8, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f1ea31, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f1ea39, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f216fd, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f2170a, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f21796, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f2179e, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f218d6, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f218de, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f21f09, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f21f16, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f21fa2, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f21faa, Kind::index_shift, "c1e20a", "c1e20c"),
patch(0x04f220e8, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f220f0, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2b166, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f2b16b, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2b2e4, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f2b2ec, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2b38c, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b3c3, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b3fd, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b437, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b471, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b4ab, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b4e5, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2b51f, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2e6e6, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f2e6eb, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2e877, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f2e87f, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2e92c, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2e963, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2e99d, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2e9d7, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ea11, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ea4b, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ea85, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2eabf, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ec86, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f2ec8b, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2ee27, Kind::index_shift, "c1e90a", "c1e90c"),
patch(0x04f2ee2f, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f2eedc, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ef13, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ef4d, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2ef87, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2efc1, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2effb, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2f035, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f2f06f, Kind::page_bytes, "41b800200000", "41b800800000"),
patch(0x04f34fea, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f34ff1, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f35b60, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f35b66, Kind::index_shift, "41c1e00a", "41c1e00c"),
patch(0x04f38418, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f3841f, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f38958, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f3895f, Kind::index_shift, "c1e10a", "c1e10c"),
patch(0x04f38a78, Kind::index_shift, "c1e80a", "c1e80c"),
patch(0x04f38a7f, Kind::index_shift, "c1e10a", "c1e10c"),
};
} // namespace dingosdk::game::build::v20260929::entity_pages
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <string_view>
namespace dingosdk::game::build::v20260929::hud_corner {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// skate.'s HUD in the bottom left corner is HudViewModel's DpadScoringStack (UI/Features/HUD/
// HudViewModel; PersistentHUDCore's DpadScoring_Stack shows it), a stack view model
// (UI/Foundations/Components/Stack/StackViewModel) of stack item view models. In free roam it holds
// the d-pad menu (HUDDpad_Widget) at priority 1; a scoring line pushes the score HUD
// (ScoringHUD_Widget) at priority 3. HudViewModel has two roots; the d-pad lives in one of them.
//
// What shows, read from the widgets' own logic and measured in play on 2026-10-07:
// - the d-pad hides itself while it is not alone: HUDDpad_Widget's Root_Container shows only while
// the stack's items it counts number exactly 1 (CompareInt A=B, B 1). An item at priority 2
// counts; one at priority 4 does not (it stays in the stack, active, and changes nothing).
// - the score HUD never hides for the stack: it shows by ScoringHUDViewModel alone (below).
inline constexpr std::uint32_t hud_view_schema = 0xe4c44873; // HudViewModel
inline constexpr std::uint32_t dpad_scoring_stack = 0xeae7b84f; // its DpadScoringStack
inline constexpr std::string_view dpad_widget = "UI/Features/HUD/Dpad/Widgets/HUDDpad_Widget";
inline constexpr std::int32_t dpad_priority = 1, score_priority = 3;
// StackViewModel
inline constexpr std::uint32_t stack_items = 0x61742cb4; // Items
inline constexpr std::uint32_t default_transition = 0x54c9abee; // DefaultTransitionStyle, which a push gives each item
// StackItemViewModel
inline constexpr std::uint32_t item_key = 0x1e95752c; // uint32 Key: the stack tells its items apart by it
inline constexpr std::uint32_t item_priority = 0x644a8875; // int32 Priority
inline constexpr std::uint32_t item_transition = 0xd78d240b; // Transition
inline constexpr std::uint32_t item_content = 0x716496c8; // Content: a presenter (its blueprint and data)
inline constexpr std::uint32_t content_blueprint = 0x8d1441c7;
// The score HUD (ui/features/hud/scoring/ScoringHUD_Widget) shows the game's UI model
// ScoringHUDViewModel, one root of the UI model registry, which its own widget logic writes on its
// own thread (expression functions on score events: no native code names the fields).
// HudWidgetActive shows the HUD (its multiplier ring and trick list); the line label shows only
// while ExtraInfoStyle is LineScore (0; 1 ScoreInfo shows the extra info instead, 2 None neither).
inline constexpr std::uint32_t score_view_schema = 0x9b74fc7c; // ScoringHUDViewModel
inline constexpr std::uint32_t hud_widget_active = 0x3aa2f739; // bool HudWidgetActive
inline constexpr std::uint32_t extra_info_style = 0x0426783f; // int32 ScoringWidgetExtraInfoStyle
inline constexpr std::int32_t extra_info_none = 2;
}
+10
View File
@@ -41,11 +41,21 @@ inline constexpr std::uintptr_t context_insert = 0x6c6120;
inline constexpr std::uintptr_t complete_delegate = 0x58b1f00;
// Music UI initializer (hook target).
inline constexpr Fingerprint music_ui_initialize_contract{0x868760, {0x48,0x89,0x6c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x49,0x8b,0xe9,0x4d,0x8b,0xf0,0x4c,0x8b,0xfa}};
// CONFIRMED: queued selection setter (0x1406F51A0) calls the change queue,
// then the apply-only setter (0x1406F51E0). A nonzero fourth argument selects
// song favorites; zero selects playlist likes. The apply-only song path updates
// model field 2, the Favorites list and the playback playlist at manager +0x1F8.
// The playlist path updates field 7 and the Liked list at manager +0x110;
// disassembly at 0x1406F5206/0x1406F5238/0x1406F5310 confirms the split.
inline constexpr Fingerprint favorite_change_contract{0x6f51a0, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x49,0x8b,0xd9,0x49,0x8b,0xf8,0x0f,0xb6,0xf2,0x48,0x8b,0xe9,0xe8,0xa6,0x0a,0x00,0x00,0x48,0x85,0xdb,0x4c,0x8b,0xc7}};
inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x6c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x88,0x54,0x24,0x10,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x45}};
// 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}};
// 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.
inline constexpr Fingerprint playback_channel_playlist_bind_contract{0x8ca6a0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0xe8,0x2b,0xad,0xb6,0xff,0x48,0x85,0xc0,0x74,0x0e,0x4c,0x8b,0xc3,0x48,0x8b,0xd7}};
inline constexpr std::array<Fingerprint, 7> music_ui_contracts{{
music_ui_initialize_contract,
{construct_playlist, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x21,0x95,0x32,0x04,0xe8,0x94,0x30,0x5e,0x00,0x33,0xc9,0x48,0x8d}},
@@ -0,0 +1,38 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstdint>
// The game's media catalogue: thumbnails and labels of saved replay drafts (and the game's own
// park saves), kept as numbered folders with a manifest.json under <CatalogVFSRoot>/<user id>
// (Extension/Settings/replay_drafts.cpp; analysis/replay-editor.md, section 6).
namespace dingosdk::game::build::v20260929::media_catalog {
// The backend services object (engine.h backend_services) when it is the game's own.
inline constexpr std::uintptr_t services_vtable = 0x6070f98;
// lookup(services): the registered "catalog" container, or null.
inline constexpr Fingerprint lookup{0x90c420, {
0x48,0x89,0x5c,0x24,0x10,0x57,0x48,0x83,0xec,0x30,0x48,0x8d,0x99,0x90,0x04,0x00,
0x00,0x48,0x8b,0xf9,0x48,0x8b,0xcb,0xe8,0x44,0x39,0xc8,0x00,0x41,0xb9,0x07,0x00}};
// add_container(services): makes the container and registers it. The services constructor
// calls it among its online registrations (0x282fa0), none of which run offline.
inline constexpr Fingerprint add_container{0x279720, {
0x48,0x8b,0xc4,0x53,0x57,0x41,0x56,0x48,0x83,0xec,0x50,0x48,0x89,0x68,0x20,0x48,
0x89,0x70,0xe0,0x4c,0x89,0x60,0xd8,0x4c,0x89,0x68,0xd0,0x4c,0x8b,0xe9,0x48,0x8b}};
// user_added(listener, &user): the container's answer to a user signing in (its listener, at
// container + 0x60, is subscribed to "user_manager" when there is one). It makes the catalogue
// for the user's id (an engine string at user + 0x18) and keeps it at container + 0x68: the
// constructor (0x63be70) lists the folder and loads each entry (0x659450 -> 0x657480), then a
// posted task (0x659980) sets the catalogue's ready flag.
inline constexpr Fingerprint user_added{0x651bb0, {
0x48,0x89,0x5c,0x24,0x20,0x56,0x48,0x83,0xec,0x30,0x48,0x8b,0xda,0x48,0x89,0x6c,
0x24,0x48,0x48,0x8b,0x15,0x1f,0x6e,0xe3,0x06,0x48,0x8b,0xe9,0xb9,0x60,0x00,0x00}};
inline constexpr std::uintptr_t container_vtable = 0x60cd120;
inline constexpr std::uintptr_t listener_vtable = 0x60cd140;
inline constexpr std::uintptr_t container_listener = 0x60;
inline constexpr std::uintptr_t container_catalogue = 0x68;
// In a catalogue: its folder (an engine string), its entries (0x130 bytes each) and whether
// its first load is done. Updating and deleting an entry need the flag.
inline constexpr std::uintptr_t catalogue_folder = 0x0;
inline constexpr std::uintptr_t catalogue_entries = 0x38;
inline constexpr std::uintptr_t catalogue_entry_size = 0x130;
inline constexpr std::uintptr_t catalogue_ready = 0x5c;
}
@@ -35,4 +35,18 @@ inline constexpr std::uintptr_t named_lookup = 0x16fc900;
inline constexpr std::array<unsigned char, 24> named_lookup_prefix{
0x40,0x55,0x56,0x57,0x41,0x56,0x48,0x81,0xec,0xd8,0x02,0x00,0x00,0x48,0x8b,0x05,
0xac,0x7a,0xac,0x05,0x48,0x33,0xc4,0x48};
// The skater scripts' item:addEsShaderPreset(material, preset): a Lua function (state) -> count
// of results. Its third argument is a reference to the loaded preset, which it uses without
// checking that the preset is there: an item whose preset has gone (or never loaded) by the
// time its script runs crashes the game in the material's construction.
inline constexpr std::uintptr_t add_shader_preset = 0x52cb70;
inline constexpr std::array<unsigned char, 16> add_shader_preset_prefix{
0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x28,0x48,0x8b,0xd9};
// The two calls it makes to read that argument: (state, argument number) -> the reference, and
// (reference, 0) -> the preset it holds, or null.
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;
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};
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <array>
#include <cstdint>
// Skater effects (sparks, dust, landing puffs, costume and board effects): the game's skater
// effects manager.
namespace dingosdk::game::build::v20260929::native_vfx {
struct Function {
std::uintptr_t rva;
std::array<unsigned char, 16> prefix;
};
// (manager, key, component): a skater's effects component registers with the manager.
inline constexpr Function register_skater{
0x4874800, {0x48,0x8b,0xc4,0x53,0x48,0x83,0xec,0x60,0x48,0x89,0x68,0x08,0x48,0x8d,0x59,0x78}};
// (manager, key, impact): the effect of one contact with the world, chosen by the surface's
// material and the speed. Each call is one effect that ends by itself. Reads the calling
// thread's world.
inline constexpr Function impact{
0x486cda0, {0x48,0x8b,0xc4,0x4c,0x89,0x40,0x18,0x48,0x89,0x50,0x10,0x48,0x89,0x48,0x08,0x48}};
// (manager, key): the skater's effects are stopped and built again from its settings at the
// manager's next update. Takes the manager's own lock.
inline constexpr Function refresh{
0x4874a20, {0x40,0x53,0x48,0x83,0xec,0x40,0x48,0x89,0x6c,0x24,0x50,0x48,0x8d,0x99,0xb0,0x00}};
// (manager, uint64 *effects_entity, int id, settings, char flag, char queued): builds a skater's
// effects from its settings, a pointer to an array of effect sets (pointers, bit 2 a flag;
// the count sits four bytes before the first). Called for each refreshed skater from the
// manager's update.
inline constexpr Function build_effects{
0x48700c0, {0x44,0x89,0x44,0x24,0x18,0x48,0x89,0x54,0x24,0x10,0x48,0x89,0x4c,0x24,0x08,0x55}};
// Manager: the root of its map of registered skaters; a node's id and effects component.
inline constexpr std::size_t manager_skaters_root = 0x58, skater_node_id = 0x20, skater_node_component = 0x30;
// An effects component: what holds its entity.
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}};
// 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,
item_joint = 0x44;
// How an effect set is started, and the value for one that is always on.
inline constexpr std::size_t set_trigger = 0x80;
inline constexpr std::int32_t trigger_always = 3;
// (out): the calling thread's world, or null.
inline constexpr Function thread_world{
0x18b6ae0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x65,0x48,0x8b,0x04,0x25,0x58}};
// Offsets into the world, each stored as a 32-bit value: the effects manager, and the object
// that holds the level's material grid.
inline constexpr std::uintptr_t manager_offset = 0x77abaa8, materials_offset = 0x74f8e38;
inline constexpr std::size_t manager_ready = 0x240; // byte: the manager is in use
inline constexpr std::size_t materials_ready = 0x50; // byte: the grid object is in use
inline constexpr std::size_t materials_grid = 0x28; // the grid
inline constexpr std::size_t grid_data = 8; // its data (bit 2 is a flag)
inline constexpr std::size_t grid_index_table = 0x48; // data: pointer to the index table
// The effects component of a skater entity.
inline constexpr std::uintptr_t component_vtable = 0x65ec508;
inline constexpr std::size_t component_size = 0x68;
// The impact: position, velocity and normal as four floats each, then the material.
inline constexpr std::size_t impact_size = 0x40, impact_velocity = 0x10, impact_normal = 0x20, impact_material = 0x30;
inline constexpr std::uint32_t material_valid = 0x20;
inline constexpr unsigned material_shift = 6;
}
@@ -35,4 +35,16 @@ inline constexpr std::array<OffboardFlightState, 3> offboard_flight_states{{
0xe3,0x71,0x21,0x4f,0x18,0x20,0xc5,0xe8,0x58,0xc1,0xc5,0xf8,0x11,0x06,0x4c,0x8b}}},
}};
inline constexpr std::uintptr_t offboard_flight_vtable = 0x65e8f40;
// FollowRagdoll / FollowAnimatedRagdoll / FollowSimulatedRagdoll also keep
// world velocity at +10. Their shared update derives it from displacement
// (RVA 48380DE), including Y at +14. Boosts must update this cached velocity
// as well as the skeleton bodies when diving or gliding.
inline constexpr std::array<std::array<std::uintptr_t, 3>, 3> offboard_ragdoll_states{{
{0x720, 0x65e8df8, 0x4832c80},
{0x8e0, 0x65e8e60, 0x4830a70},
{0xaa0, 0x65e8ed0, 0x48332f0},
}};
inline constexpr Fingerprint ragdoll_velocity_write{0x48380de, {
0xc4,0xe2,0x79,0x18,0x80,0xec,0x17,0x00,0x00,0xc5,0xe8,0x5e,0xc8,0xc5,0xf8,0x11,
0x4f,0x10,0xc5,0xf8,0x10,0x43,0x30,0xc5,0xf8,0x11,0x87,0x60,0x01,0x00,0x00,0x4c}};
}
+137 -137
View File
@@ -12,7 +12,7 @@ using entity_pages::patch;
// treating its allocator pointer and metadata as additional page pointers.
inline constexpr std::size_t entry_bytes = 24;
inline constexpr std::size_t page_bytes = entity_pages::page_entries * entry_bytes;
static_assert(page_bytes == 0x30000);
static_assert(page_bytes == 0x18000);
// Keep owner offsets and stack-frame layouts intact. Disable only the two
// undersized inline pages; the existing lazy allocator/destructor owns every
@@ -21,141 +21,141 @@ static_assert(page_bytes == 0x30000);
inline constexpr Patch patches[]{
patch(0x048a7450, Kind::pending_update_pages, "41c7800c04000002000000", "41c7800c04000000000000"),
patch(0x048a745b, Kind::pending_update_pages, "833db6cf910206", "e9300000009090"),
patch(0x048a944c, Kind::pending_update_pages, "41b800600000", "41b800000300"),
patch(0x048abf3e, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x048abf44, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048afad8, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048afae2, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048bdd9b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048bdda9, Kind::pending_update_pages, "41c1e20a", "41c1e20d"),
patch(0x048bddcb, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048bddd2, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048be423, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048be437, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x048be45b, Kind::pending_update_pages, "c1e70a", "c1e70d"),
patch(0x048bffb9, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048bffbe, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x048bfff2, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048bfff9, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048c0012, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048c0017, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x048c04c2, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x048c04c8, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048c0e14, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x048c0e1c, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eb2fce, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04eb2fd4, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eb30ae, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04eb30b4, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eb318e, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04eb3194, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eef54b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef559, Kind::pending_update_pages, "41c1e20a", "41c1e20d"),
patch(0x04eef57b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef582, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eef61b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef629, Kind::pending_update_pages, "41c1e20a", "41c1e20d"),
patch(0x04eef64b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef652, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04eef6fb, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef709, Kind::pending_update_pages, "41c1e20a", "41c1e20d"),
patch(0x04eef72b, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04eef732, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef55da, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04ef5601, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef5628, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef566f, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef569d, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef56a5, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef56b4, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04ef574d, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef576f, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef579b, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef5813, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef583c, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef5863, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef58aa, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef58d8, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef58e0, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef58ef, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04ef5971, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5978, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef59a4, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef59ab, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef5a72, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5a91, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef5ab8, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef5b01, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5b06, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04ef5b4f, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5b6e, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef5b95, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef5bdd, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5be2, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04ef5c1f, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5c26, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04ef5c41, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04ef5c46, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04ef5e59, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04ef5e78, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef5e97, Kind::pending_update_pages, "c1e60a", "c1e60d"),
patch(0x04ef5feb, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04ef600a, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04ef6029, Kind::pending_update_pages, "c1e60a", "c1e60d"),
patch(0x04ef61af, Kind::pending_update_pages, "418d8dff030000", "418d8dff1f0000"),
patch(0x04ef61b6, Kind::pending_update_pages, "48c1e90a", "48c1e90d"),
patch(0x04ef61f3, Kind::pending_update_pages, "b800040000", "b800200000"),
patch(0x04ef628b, Kind::pending_update_pages, "4181c500fcffff", "4181c500e0ffff"),
patch(0x04ef630d, Kind::pending_update_pages, "41b800600000", "41b800000300"),
patch(0x04efbdf3, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04efbe1a, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efbe41, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efbe88, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efbeb6, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efbebe, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efbecd, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04efbf6c, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efbf8e, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efbfba, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc035, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc062, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc08e, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc0e9, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc117, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc11f, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc12e, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04efc202, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc221, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc248, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc2c2, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc2e1, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc308, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc353, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc358, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04efc399, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc3a0, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04efc3bb, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04efc3c0, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04efc5ef, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04efc60e, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc62d, Kind::pending_update_pages, "c1e60a", "c1e60d"),
patch(0x04efc78e, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04efc7ad, Kind::pending_update_pages, "ba00600000", "ba00000300"),
patch(0x04efc7cc, Kind::pending_update_pages, "c1e60a", "c1e60d"),
patch(0x04efc96c, Kind::pending_update_pages, "418d8dff030000", "418d8dff1f0000"),
patch(0x04efc973, Kind::pending_update_pages, "48c1e90a", "48c1e90d"),
patch(0x04efc9b3, Kind::pending_update_pages, "b800040000", "b800200000"),
patch(0x04efca54, Kind::pending_update_pages, "4181c500fcffff", "4181c500e0ffff"),
patch(0x04efcad9, Kind::pending_update_pages, "41b800600000", "41b800000300"),
patch(0x04f095de, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04f095e3, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04f0961e, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04f09625, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04f0963e, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04f09643, Kind::pending_update_pages, "c1e00a", "c1e00d"),
patch(0x04f09af2, Kind::pending_update_pages, "c1e90a", "c1e90d"),
patch(0x04f09af8, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x04f0db17, Kind::pending_update_pages, "c1e80a", "c1e80d"),
patch(0x04f0db1f, Kind::pending_update_pages, "c1e10a", "c1e10d"),
patch(0x048a944c, Kind::pending_update_pages, "41b800600000", "41b800800100"),
patch(0x048abf3e, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x048abf44, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x048afad8, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048afae2, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x048bdd9b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048bdda9, Kind::pending_update_pages, "41c1e20a", "41c1e20c"),
patch(0x048bddcb, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048bddd2, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x048be423, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048be437, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x048be45b, Kind::pending_update_pages, "c1e70a", "c1e70c"),
patch(0x048bffb9, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048bffbe, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x048bfff2, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048bfff9, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x048c0012, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048c0017, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x048c04c2, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x048c04c8, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x048c0e14, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x048c0e1c, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eb2fce, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04eb2fd4, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eb30ae, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04eb30b4, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eb318e, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04eb3194, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eef54b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef559, Kind::pending_update_pages, "41c1e20a", "41c1e20c"),
patch(0x04eef57b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef582, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eef61b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef629, Kind::pending_update_pages, "41c1e20a", "41c1e20c"),
patch(0x04eef64b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef652, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04eef6fb, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef709, Kind::pending_update_pages, "41c1e20a", "41c1e20c"),
patch(0x04eef72b, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04eef732, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef55da, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04ef5601, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef5628, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef566f, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef569d, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef56a5, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef56b4, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04ef574d, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef576f, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef579b, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef5813, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef583c, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef5863, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef58aa, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef58d8, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef58e0, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef58ef, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04ef5971, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5978, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef59a4, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef59ab, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef5a72, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5a91, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef5ab8, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef5b01, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5b06, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04ef5b4f, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5b6e, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef5b95, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef5bdd, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5be2, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04ef5c1f, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5c26, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04ef5c41, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04ef5c46, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04ef5e59, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04ef5e78, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef5e97, Kind::pending_update_pages, "c1e60a", "c1e60c"),
patch(0x04ef5feb, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04ef600a, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04ef6029, Kind::pending_update_pages, "c1e60a", "c1e60c"),
patch(0x04ef61af, Kind::pending_update_pages, "418d8dff030000", "418d8dff0f0000"),
patch(0x04ef61b6, Kind::pending_update_pages, "48c1e90a", "48c1e90c"),
patch(0x04ef61f3, Kind::pending_update_pages, "b800040000", "b800100000"),
patch(0x04ef628b, Kind::pending_update_pages, "4181c500fcffff", "4181c500f0ffff"),
patch(0x04ef630d, Kind::pending_update_pages, "41b800600000", "41b800800100"),
patch(0x04efbdf3, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04efbe1a, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efbe41, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efbe88, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efbeb6, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efbebe, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efbecd, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04efbf6c, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efbf8e, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efbfba, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc035, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc062, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc08e, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc0e9, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc117, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc11f, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc12e, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04efc202, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc221, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc248, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc2c2, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc2e1, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc308, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc353, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc358, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04efc399, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc3a0, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04efc3bb, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04efc3c0, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04efc5ef, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04efc60e, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc62d, Kind::pending_update_pages, "c1e60a", "c1e60c"),
patch(0x04efc78e, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04efc7ad, Kind::pending_update_pages, "ba00600000", "ba00800100"),
patch(0x04efc7cc, Kind::pending_update_pages, "c1e60a", "c1e60c"),
patch(0x04efc96c, Kind::pending_update_pages, "418d8dff030000", "418d8dff0f0000"),
patch(0x04efc973, Kind::pending_update_pages, "48c1e90a", "48c1e90c"),
patch(0x04efc9b3, Kind::pending_update_pages, "b800040000", "b800100000"),
patch(0x04efca54, Kind::pending_update_pages, "4181c500fcffff", "4181c500f0ffff"),
patch(0x04efcad9, Kind::pending_update_pages, "41b800600000", "41b800800100"),
patch(0x04f095de, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04f095e3, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04f0961e, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04f09625, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04f0963e, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04f09643, Kind::pending_update_pages, "c1e00a", "c1e00c"),
patch(0x04f09af2, Kind::pending_update_pages, "c1e90a", "c1e90c"),
patch(0x04f09af8, Kind::pending_update_pages, "c1e10a", "c1e10c"),
patch(0x04f0db17, Kind::pending_update_pages, "c1e80a", "c1e80c"),
patch(0x04f0db1f, Kind::pending_update_pages, "c1e10a", "c1e10c"),
};
} // namespace dingosdk::game::build::v20260929::pending_updates
@@ -0,0 +1,19 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstdint>
// The game's rolling replay recording (Extension/Settings/replay_guard.cpp).
namespace dingosdk::game::build::v20260929::replay_recorder {
// end_chunk(recorder, force): run on a job worker as the game plays. Every recorder + 0x110
// frames, or when the stream (recorder + 0x950) is as full as recorder + 0x30 allows, it ends
// the chunk being recorded (recorder + 0xa80) with a "Tail" marker and starts the next: it has
// the stream drop old chunks (0x48f0e30), then asks it for the new chunk's head (0x48fb5a0, an
// allocation tagged "rplyFreezeClip"). It returns whether a chunk was ended (al).
inline constexpr Fingerprint end_chunk{0x48ef8a0, {
0x40,0x53,0x55,0x57,0x48,0x81,0xec,0xc0,0x01,0x00,0x00,0x48,0x8b,0x05,0x0e,0x4b,
0x8d,0x02,0x48,0x33,0xc4,0x48,0x89,0x84,0x24,0x90,0x01,0x00,0x00,0xc5,0xfb,0x10}};
// The store of the new head's first field, right after that allocation, with no check that the
// stream had room for one: an access violation writing 0x24 when it had none (crash reports of
// 2026-10-10, v2.0.3, skating on a server).
inline constexpr std::uintptr_t end_chunk_head_store = 0x48efa34;
}
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
namespace dingosdk::game::build::v20260929::skater_body {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The skater's body (Engine/Game/Skater/skater_body.h), per physics step: how hard each
// body hit something, and where its joint is. Measured in Ghidra on 2026-10-04; the ragdoll's skeleton publisher and its
// body maps were first found by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate).
//
// The physics bone ids are the order of the game's physics bone name map (0xfd6600), one
// config field per name.
//
// The physics step of a skater's core (0x47da350) first processes the rig's body
// contacts (0x4777b60 -> process_body_contacts), then hands the same rig to the
// skeleton response (no_bail.h bail_skeleton_contract). So at the skeleton
// response the contact struct holds this step's values.
inline constexpr Fingerprint contacts_before_skeleton_contract{0x47da3b9, {
0x48,0x8b,0x8b,0xc0,0x03,0x00,0x00,0x45,0x33,0xc0,0xc5,0xfa,0x10,0x89,0xec,0x17,
0x00,0x00,0x48,0x8b,0xc8,0xe8,0x8d,0xd7,0xf9,0xff,0x48,0x8b,0x8b,0xb0,0x03,0x00}};
// rig+0x2f10 -> holder, holder+0x1040 -> the contact struct process_body_contacts
// takes in RCX.
inline constexpr Fingerprint contact_holder_contract{0x4777c75, {
0x48,0x8b,0xb3,0x10,0x2f,0x00,0x00,0x44,0x8b,0x8a,0x18,0x14,0x00,0x00,0xc5,0xf8,
0x11,0x45,0xe7,0xc5,0xf8,0x10,0x80,0x40,0x07,0x00,0x00,0x0f,0xb6,0x86,0x95,0x1c}};
inline constexpr Fingerprint contact_struct_contract{0x4777d64, {
0x48,0x8b,0x8e,0x40,0x10,0x00,0x00,0x48,0x8d,0x55,0xc7,0xc5,0xf8,0x77,0xe8,0xf9,
0xaf,0xff,0xff,0x80,0xbe,0x94,0x1c,0x00,0x00,0x00,0x74,0x51,0x4c,0x89,0xb4,0x24}};
// process_body_contacts (0x4772d70) clears one 0x70-byte record per body bone at
// contact+0x5e0 at the start of every step: 0x18 bones, ids 0..23.
inline constexpr Fingerprint bone_record_reset_contract{0x4772f76, {
0x48,0x83,0xf9,0x18,0x0f,0x85,0x60,0xff,0xff,0xff,0x4c,0x89,0x9f,0x70,0x03,0x00,
0x00,0x4c,0x89,0x9f,0x90,0x03,0x00,0x00,0x4c,0x89,0x9f,0xb0,0x03,0x00,0x00,0x4c}};
// For each contact of a bone id above 0 it keeps the step's largest impact speed
// in that bone's record: |dot(contact normal, other body velocity - bone velocity)|,
// metres per second along the normal (times a mass-ratio factor for a few contact
// kinds). +0x50 holds ordinary contacts; +0x54 the ones near one of two tracked points
// (their bones at contact+0x1060/0x1064: not only feet, the head was one in a bail).
inline constexpr Fingerprint bone_peak_contract{0x47738bf, {
0x48,0x6b,0xcb,0x70,0xba,0x50,0x00,0x00,0x00,0xb8,0x54,0x00,0x00,0x00,0x48,0x81,
0xc1,0xe0,0x05,0x00,0x00,0x48,0x03,0xcf,0x41,0x83,0xfc,0x02,0x0f,0x44,0xc2,0x48}};
// The bone record in full (0x477396f for ordinary contacts, 0x4773936 for those near a tracked
// point), written for a contact of a bone above 0 when it beats the record's peak: the contact
// normal (+0x00, tracked +0x10), the normal crossed with the relative velocity (+0x20, tracked
// +0x30: its length is how fast the bone moves along the surface, which the bails of 2026-10-06
// confirm), the contact point (+0x40) and the peak (+0x50, tracked +0x54). +0x58/+0x5c keep the
// contact's float +0x44, which stayed 0 in every contact of those bails.
inline constexpr Fingerprint bone_record_fill_contract{0x477396f, {
0xc5,0xfa,0x11,0x61,0x50,0x8b,0x44,0x24,0x40,0x89,0x01,0xc5,0xfa,0x10,0x44,0x24,
0x44,0xc5,0xfa,0x11,0x41,0x04,0x48,0x8b,0x44,0x24,0x48,0x48,0x89,0x41,0x08,0xc5}};
// Then it marks the kind of what the bone hit (the other collidable's object +0x23b0, else the
// contact's own +0x7c): +0x64 kind 4, the board (the feet-on-board test asks for it); +0x60
// kind 8, vehicles (a bail into a car, 2026-10-06); +0x63 any other kind, the static world
// (ground, walls and a rail in those bails); +0x62 kind 5 and +0x61 kind 11, not met yet.
inline constexpr Fingerprint bone_hit_kinds_contract{0x47739d4, {
0x08,0x41,0x63,0x44,0x08,0x41,0x60,0x44,0x08,0x49,0x61,0x44,0x08,0x51,0x62,0x41,
0x83,0xff,0x04,0x0f,0x94,0xc0,0x08,0x41,0x64,0x45,0x84,0xdb,0x0f,0x84,0x9d,0x00}};
// Per bone besides the records: a byte at contact+0x340 is set while the bone touches something in
// this step, cleared at the start of each step and set by a contact (beside the bone's contact timer
// at contact+0x280, which a contact zeroes).
inline constexpr Fingerprint bone_contact_timer_contract{0x4772ee0, {
0xc5,0xfa,0x10,0x84,0x8f,0x80,0x02,0x00,0x00,0xc5,0xfa,0x5c,0x4a,0x60,0x48,0x6b,
0xc1,0x70,0xc5,0xfa,0x11,0x8c,0x8f,0x80,0x02,0x00,0x00,0x44,0x88,0x9c,0x39,0x40}};
inline constexpr Fingerprint bone_contact_contract{0x4773529, {
0xc7,0x84,0x9f,0x80,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0xc6,0x84,0x3b,0x40,0x03,
0x00,0x00,0x01,0xc5,0xfa,0x10,0x84,0x9f,0xe0,0x02,0x00,0x00,0xc4,0xc1,0x78,0x2f}};
// Everything above, verified once before any of it is read.
inline constexpr std::array<Fingerprint, 9> contracts{contacts_before_skeleton_contract, contact_holder_contract,
contact_struct_contract, bone_record_reset_contract, bone_peak_contract, bone_record_fill_contract,
bone_hit_kinds_contract, bone_contact_timer_contract, bone_contact_contract};
// The core's step input (published at core+0x3c8 by core_set_pose_input): +0x20 is the length of the
// skater's physics step in seconds, copied to the context's +0x17ec every update (core_apply_step_input)
// and handed to the contacts and the skeleton. Taken from the simulation rate when the skater spawns
// or is teleported, never after (measured 2026-10-08).
inline constexpr std::uintptr_t core_step_input_offset = 0x3c8; // core
inline constexpr std::uintptr_t step_input_length_offset = 0x20; // step input, float seconds
inline constexpr std::uintptr_t contact_holder_offset = 0x2f10; // rig
inline constexpr std::uintptr_t contact_struct_offset = 0x1040; // holder
inline constexpr std::uintptr_t bone_records_offset = 0x5e0; // contact struct
inline constexpr std::uintptr_t bone_record_size = 0x70;
inline constexpr std::size_t body_bone_count = 0x18;
inline constexpr std::uintptr_t bone_peak_offset = 0x50; // float, ordinary contacts
inline constexpr std::uintptr_t bone_tracked_peak_offset = 0x54; // float, near a tracked point
inline constexpr std::uintptr_t bone_slide_offset = 0x20; // float[4], and its tracked one at +0x30
inline constexpr std::uintptr_t bone_tracked_offset = 0x10; // from an ordinary field to its tracked one
inline constexpr std::uintptr_t bone_hit_vehicle_offset = 0x60; // bytes
inline constexpr std::uintptr_t bone_hit_kind_11_offset = 0x61;
inline constexpr std::uintptr_t bone_hit_kind_5_offset = 0x62;
inline constexpr std::uintptr_t bone_hit_world_offset = 0x63;
inline constexpr std::uintptr_t bone_hit_board_offset = 0x64;
inline constexpr std::uintptr_t bone_touching_offset = 0x340; // contact struct, byte per bone
}
@@ -0,0 +1,79 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <array>
#include <cstddef>
#include <cstdint>
namespace dingosdk::game::build::v20260929::skater_render {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The local skater as the renderer draws it: the skinning matrices of its mesh and the camera of
// the same picture (Extension/HallOfMeat/hall_of_meat_render.h). Read 2026-10-06; the draw packet,
// the render view and the handle chain were first used by gBGYo's ReSkate fork
// (github.com/gBGYo/ReSkate).
//
// A skinned render object hands its draw packet out through 0x48db8e0(object, packet), packing
// the bones first when they changed (0x1448d2a30): each bone's skinning matrix (vertex in the
// mesh's model space to the world, the skeleton's inverse bind already in it) placed by the
// object's world matrix, three rows of four floats, transposed. The placement leaves the world
// position's whole metres out: the packet's root (float[4]) adds them back, and a relative packet
// is in its actor's frame (the packet's first matrix) besides. Bone 0 keeps its own position.
inline constexpr Fingerprint draw_packet_contract{0x48db8e0, {
0x4c,0x8b,0xdc,0x55,0x56,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x48,0x8b,0x41,
0x28,0x48,0x8b,0xf2,0x48,0x8b,0xf9,0x48,0x8b,0x68,0x40,0x48,0x85,0xed,0x0f,0x84}};
inline constexpr std::uintptr_t render_object_vtable = 0x65f2910;
inline constexpr std::uintptr_t render_index_offset = 0x108; // render object, uint32
inline constexpr std::uintptr_t render_object_lock_offset = 0x104; // render object, uint32: set while packing
inline constexpr std::uintptr_t packet_actor_offset = 0x00; // float[16], copied from object+0x50
inline constexpr std::uintptr_t packet_bones_offset = 0x80; // pointer to float[12] per bone (object+0xe0)
inline constexpr std::uintptr_t packet_bone_count_offset = 0x88; // uint32 (object+0xf8)
inline constexpr std::uintptr_t packet_root_offset = 0x90; // pointer to float[4] (object+0xd8)
inline constexpr std::uintptr_t packet_relative_offset = 0x98; // byte (object+0xfe)
inline constexpr std::uintptr_t packet_visible_offset = 0x99; // byte (object+0xff)
// Which render object is the local skater's: the skater entity keeps a render handle for riding
// and one for walking (entity+0x4d0, 8 bytes apart; each mode its own render object). A handle
// names a record in a manager's paged pool (handle - 2 is its index; index + 16 has its highest
// bit at page + 4, the pages from pool+8 on), checked in each manager's own lookup: the first
// record keeps the second manager's handle at +0x40, the second the render index + 2 at +0x0c.
inline constexpr std::uintptr_t entity_render_handles_offset = 0x4d0; // skater entity, uint32 x 2
inline constexpr std::size_t render_modes = 2;
inline constexpr std::uintptr_t entity_render_handle_stride = 8;
inline constexpr std::uint32_t first_handle = 2;
inline constexpr std::uintptr_t manager_pool_offset = 0xc8;
struct RenderManager {
std::uintptr_t global; // the manager's pointer
std::uintptr_t vtable;
std::size_t record_size;
std::uintptr_t handle_offset; // in its record: the next handle
};
inline constexpr std::array<RenderManager, 2> handle_chain{{
{0x71b78f8, 0x6671138, 0x98, 0x40},
{0x77ef158, 0x666d4c0, 0x68, 0x0c},
}};
// The managers' own lookups: the first's (0x144f0dcf0, its vtable slot 65, passes this+0x10)
// finds its 0x98-byte records in the pool at +0x10+0xb8; the second's (0x144ee23c0, reached from a
// secondary vtable) its 0x68-byte ones. That the second's pool is at +0xc8 as well, gBGYo measured.
inline constexpr Fingerprint first_lookup_contract{0x4f0dd80, {
0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x48,0x8d,0xb1,0xb8,0x00,0x00,0x00,
0x48,0x8b,0xd9,0x83,0xc2,0xfe,0xb9,0x3f,0x00,0x00,0x00,0x48,0x83,0xc2,0x10,0x48}};
inline constexpr Fingerprint second_lookup_contract{0x4ee23c0, {
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x4c,0x8d,0x89,0xb8,0xfb,0xff,
0xff,0x48,0xbb,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0x7f,0x44,0x8d,0x42,0xfe,0xb9}};
// The camera of each rendered view: 0x3f8b2b0(blackboard, current view, previous view, jitter)
// copies the 320-byte view (0x143f7e700) for the shaders. In it the camera's world matrix at
// +0x40 (rows right, up, back, position; their fourth lanes not the matrix's) and the vertical
// field of view in radians at +0x104. The main picture's view has kind 0 (uint32), 0x38 bytes
// before it.
inline constexpr Fingerprint render_view_contract{0x3f8b2b0, {
0x48,0x8b,0xc4,0x53,0x56,0x57,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x88,
0x0c,0x00,0x00,0xc5,0xf8,0x29,0x70,0xb8,0xc5,0xf8,0x29,0x78,0xa8,0xc5,0x78,0x29}};
inline constexpr std::size_t view_size = 0x140;
inline constexpr std::uintptr_t view_camera_offset = 0x40; // float[16]
inline constexpr std::uintptr_t view_fov_offset = 0x104; // float, radians
inline constexpr std::uintptr_t view_kind_back = 0x38; // uint32, before the view
inline constexpr std::uint32_t main_view_kind = 0;
inline constexpr std::array<Fingerprint, 4> contracts{draw_packet_contract, first_lookup_contract,
second_lookup_contract, render_view_contract};
}
@@ -0,0 +1,29 @@
#pragma once
#include <cstddef>
#include <string_view>
namespace dingosdk::game::build::v20260929::skater_skeleton {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The skater's skeleton as skate. itself has it, read from the installed game's data
// (Extension/HallOfMeat/hall_of_meat_skeleton.h): the player's render skeleton, and the Dem Bones
// costume, a full-body skeleton outfit skinned to it. Both were first used by gBGYo's ReSkate
// fork (github.com/gBGYo/ReSkate); read on 2026-10-06.
//
// The render skeleton: 386 bones (Hips 7, Neck1 102, Head 103, the hands with their fingers, the
// face, the board) and how they hang together.
inline constexpr std::string_view skeleton_toc = "Win32/levels/game/bam_levelroot/bam_levelroot.toc";
inline constexpr std::string_view skeleton_bundle = "win32/levels/game/bam_levelroot/bam_levelroot";
inline constexpr std::string_view skeleton_asset = "characters/maincharacters/common/animbase/animbase_default_renderskeleton";
// The costume's skinned mesh, in six levels of detail: 46,083 triangles at the finest, then
// 31,310, 22,031 (twice), 13,817 and 4,605.
inline constexpr std::string_view mesh_toc = "Win32/items.toc";
inline constexpr std::string_view mesh_bundle =
"win32/characters/maincharacters/generic/cas/clothing/unlicensed/generic/apparel/fullbody/costume/dembones/2026/"
"gen_costume_dembones_complex_dmpreset_cas_main_bundlereftable";
inline constexpr std::string_view mesh_asset =
"characters/maincharacters/generic/cas/clothing/unlicensed/generic/apparel/fullbody/costume/dembones/2026/"
"gen_costume_dembones_base_mesh";
// 13,817 triangles on 12,222 vertices: every bone's shape, few enough to pose and draw on the
// CPU every frame.
inline constexpr std::size_t mesh_lod = 4;
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "client_source_spawn.h"
#include <array>
#include <cstdint>
namespace dingosdk::game::build::v20260929::skater_state {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The skater's live state, read-only (Extension/HallOfMeat/hall_of_meat_skater.h).
// The physics state the core's selector chose this step (no_bail.h: choose_physics_state; its
// offboard_physics_state is the one on foot and through a whole bail).
inline constexpr std::uintptr_t physics_state_offset = 0x1414; // uint32, context
inline constexpr std::array<std::uint32_t, 2> board_air_states{200, 201}; // PHYSICS_AIR, KNOWN_AIR
// The core's trick state (its vtable slot +0xb0 returns core+0x3b8) holds the offboard state
// at +0x78: the skater on foot and in a ragdoll (the script native GetOffboardScoring copies
// it out). Each offboard substate class raises exactly one substate flag of it from its vtable
// (0x1465e8c28 on, each beside its name and id); the three ragdoll ones, FollowRagdoll (3)
// 0x14482d970, FollowAnimatedRagdoll (4) 0x14482d3b0 and FollowSimulatedRagdoll (5) 0x14482db00,
// the same one. In the air is the substate's own +0x150, copied over. Flags are one byte, 0 or 1.
inline constexpr std::uintptr_t trick_state_offset = 0x3b8; // core
inline constexpr std::uintptr_t offboard_state_offset = 0x78; // trick state
inline constexpr std::uintptr_t ragdoll_offset = 0x18c; // offboard state
inline constexpr std::uintptr_t in_the_air_offset = 0x180; // offboard state
// How fast the skater moves: the linear velocity of its physics bodies, read as the SDK's noclip
// reads and writes them (Extension/Skater/client_noclip.cpp). The board's physics: core+0x430 ->
// +0x18 (vtable board_physics_vtable); the skeleton's: the rig (core+0x438) -> +0x2f10 (vtable
// rig_physics_vtable). Each keeps its bodies at +0x20: their count (uint32) at the start, body n at
// n * 0x130, each pointing back to its owner at +0x10 with its velocity (float[3], m/s) at +0x70 and
// its spin (angular velocity, float[3], radians per second) at +0x90. 0x140fc06b0 syncs a body with
// the physics world: by its pending flags (+0x60) it sets the velocity (8, 0x1425da600) from +0x70
// and the spin (0x20, 0x1425d8530) from +0x90.
// The board has 9, body 0 its root; the skeleton 26, body n the ragdoll's body n (skater_body.h).
inline constexpr std::uintptr_t board_holder_offset = 0x430; // core
inline constexpr std::uintptr_t board_physics_offset = 0x18; // board holder
inline constexpr std::uintptr_t rig_physics_offset = 0x2f10; // rig
inline constexpr std::uintptr_t physics_bodies_offset = 0x20; // board or rig physics
inline constexpr std::uintptr_t physics_body_size = 0x130;
inline constexpr std::uintptr_t body_owner_offset = 0x10;
inline constexpr std::uintptr_t body_velocity_offset = 0x70;
inline constexpr std::uintptr_t body_spin_offset = 0x90;
inline constexpr std::uint32_t board_body_count = 9, rig_body_count = 26;
inline constexpr std::size_t board_root_body = 0;
inline constexpr std::uintptr_t board_physics_vtable = client_source_spawn::board_physics_vtable;
inline constexpr std::uintptr_t rig_physics_vtable = client_source_spawn::rig_physics_vtable;
}
+2 -1
View File
@@ -26,7 +26,8 @@ struct alignas(16) RootLayout {
BucketLayout nodes[nodes_per_root];
std::uint32_t node_count;
};
static_assert(blocks_per_bucket * entries_per_block == entity_pages::capacity);
// (Left at eight times the game's while the entity pages are at four: room to spare.)
static_assert(blocks_per_bucket * entries_per_block >= entity_pages::capacity);
static_assert(blocks_per_bucket < 65'536);
static_assert(offsetof(BucketLayout, block_pointers) == 0x10000);
static_assert(offsetof(BucketLayout, block_count) == 0x30000);
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <cstdint>
namespace dingosdk::game::build::v20260929::ui_model {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The UI model's write of one value (manager, handle, type, value bytes, flag; true when written),
// found on 2026-10-07. The game's widget logic is data (expression functions: no native code names
// the fields), so its writes land here too. The publish the native menu reader uses (0x1924850,
// which takes the model lock at manager + 0xf20 around it) and the model's other writers call it:
// the array element writer (0x1921e80), the path setter (0x1924570) and the data model setter
// (0x1923480), each under the same lock.
inline constexpr Fingerprint model_write{0x19248b0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x55,0x57,
0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x8b,0xec,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x4d,0x8b,0xe1,0x49}};
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <string_view>
namespace dingosdk::game::build::v20260929::ui_textures {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// Icons, logos and UI shapes skate. ships, read from the installed game's data for the overlay
// (Engine/Vfs/game_textures.h): where each texture is, by superbundle TOC, bundle and name.
// Found with a scan of every texture the game ships (78,566 in 6,056 bundles) on 2026-10-06.
// A part of a picture, as fractions of its width and height.
struct Region {
float left{}, top{}, right{1}, bottom{1};
};
struct Texture {
std::string_view toc, bundle, name;
Region region{}; // the part of the texture that is the picture
// A shape's: the part of the picture a box is laid on (a brush stroke's bar, without its
// splatter), and how much of each edge a panel keeps unstretched at any size (nine-slice), as a
// fraction of the picture's width and height.
Region body{};
float slice{};
};
// skate.'s scoring icons: white on clear, 64 pixels (the wipeout's broken one 68 x 64).
inline constexpr std::string_view scoring_toc = "Win32/globals.toc";
inline constexpr std::string_view scoring_bundle = "win32/configurations/gameconfigurations/delmargameconfiguration";
inline constexpr Texture airtime{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_airtime_64_64_64"};
inline constexpr Texture wipeout{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_wipeout_64_64_64"};
inline constexpr Texture wipeout_broken{scoring_toc, scoring_bundle,
"ui/textures/icons/scoring/img_icon_scoring_wipeout_broken_64_68_64"};
inline constexpr Texture spread_eagle{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_spreadeagle_64_64_64"};
inline constexpr Texture gap_height{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_gapheight_64_64_64"};
inline constexpr Texture roll{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_roll_64_64_64"};
// The game's level roots, whose bundles hold most of its UI textures.
inline constexpr std::string_view root_toc = "Win32/levels/game/dingolevel_root/dingolevel_root.toc";
inline constexpr std::string_view root_bundle = "win32/levels/game/dingolevel_root/dingolevel_root";
inline constexpr std::string_view bam_toc = "Win32/levels/game/bam_levelroot/bam_levelroot.toc";
inline constexpr std::string_view bam_bundle = "win32/levels/game/bam_levelroot/bam_levelroot";
// The stopwatch of the game's timers, white on clear.
inline constexpr Texture stopwatch{root_toc, root_bundle, "ui/textures/icons/generic/img_generic_stopwatch_64_64_64"};
// The speed line challenge's flaming wheel: white and black on clear.
inline constexpr Texture flaming_wheel{bam_toc, bam_bundle,
"ui/textures/icons/activities/challenges/icon/img_icon_speedlinechallenge_256_128_128"};
// The THRASHER wordmark: the THRASHER / SKATEBOARD MAGAZINE logo of the Thrasher tee (512 x 256, grey on
// clear), its wordmark alone (pixels 13 to 498 across, 39 to 192 down; the subtitle is below). Drawn in
// true proportion, unlike the logos squeezed for a garment. Streamed: its bundle keeps only the small mips.
inline constexpr Texture thrasher_wordmark{"Win32/items.toc",
"win32/characters/maincharacters/generic/cas/clothing/licensed/thrasher/apparel/top/shirt/tshirtrelaxed/2023/colorways/"
"thrasher_shirt_tshirtrelaxed_00004_ap_cas_main_bundlereftable",
"characters/materials/logo/licensed/thrasher/logo_thrasher_2x1_011_co", {13.0f / 512, 39.0f / 256, 499.0f / 512, 193.0f / 256}};
// The wordmark arches: between a fifth in from either side its letters end above this, as a fraction
// of its height (at 55% in the middle, 69% a fifth in), leaving room beneath the arch.
inline constexpr float thrasher_wordmark_arch = 0.70f;
// skate.'s UI shapes, the stuff its menus are built from: white on clear, tinted as they are drawn.
// A tile with rough, hand-cut edges (256 x 256): about 16 pixels of each edge are rough.
inline constexpr Texture rough_tile{root_toc, root_bundle, "ui/textures/common/tiles/img_tilebackground_roughfull_256_256_256",
{}, {}, 32.0f / 256};
// A film's scratches and dust, light on black (2048 x 2048, opaque): drawn by their brightness over a panel.
inline constexpr Texture scratches{root_toc, root_bundle,
"ui/textures/common/backgrounds/img_background_slicedgrainytexture01_2048_2048_2048"};
// A short brush stroke with splatter (692 x 260): the bar is pixels 68 to 610 across, 79 to 184 down.
inline constexpr Texture brush_bar{bam_toc, bam_bundle, "ui/textures/common/banner/img_bannerbrush_small_692_692_260",
{}, {68.0f / 692, 79.0f / 260, 611.0f / 692, 185.0f / 260}};
// A long brush streak, thick on the left and tapering right (1024 x 152): pixels 8 to 1013 across, 30 to 115 down.
inline constexpr Texture streak{root_toc, root_bundle, "ui/textures/common/brushs/img_brush_splashstreak_1024_1024_152",
{}, {8.0f / 1024, 30.0f / 152, 1014.0f / 1024, 116.0f / 152}};
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <array>
#include <cstdint>
// Level unload crash guard. A job run while a level unloads walks each unloading object's table
// of 0x30-byte entries and reads a number at +0xc8 of the asset each entry points to
// (`mov rax,[rbx+8]; and rax,-5; mov edx,[rax+0xc8]`). An entry whose asset never resolved
// holds null there and the game crashes (seen leaving a mod map on a busy server). The loop is
// rewritten in place to skip such an entry: the 2-byte nop before it makes room for a `jz`
// straight after the `and`, the two moves the jump displaced follow it, and the loop's jump
// back is retargeted to the new start. Everything from the read on is where it was.
namespace dingosdk::game::build::v20260929::unload_guard {
inline constexpr std::uintptr_t rva = 0x3f5fc3e;
inline constexpr std::array<unsigned char, 50> contract{
0x66,0x90, // nop
0x48,0x8b,0x43,0x08, // loop: mov rax,[rbx+8]
0x4c,0x8b,0xcd, // mov r9,rbp
0x44,0x8b,0x43,0x24, // mov r8d,[rbx+0x24]
0x48,0x83,0xe0,0xfb, // and rax,-5
0x48,0x8b,0xcf, // mov rcx,rdi
0x8b,0x90,0xc8,0x00,0x00,0x00, // mov edx,[rax+0xc8]
0x8b,0x43,0x20,0x48,0xc1,0xe2,0x20,0x48,0x0b,0xd0,0xe8,0x99,0xa5,0xff,0xff,
0x48,0x83,0xc3,0x30, // next: add rbx,0x30
0x48,0x3b,0xde,0x75,0xd0}; // cmp rbx,rsi; jne loop
inline constexpr std::array<unsigned char, 50> patch{
0x48,0x8b,0x43,0x08, // loop: mov rax,[rbx+8]
0x48,0x83,0xe0,0xfb, // and rax,-5
0x74,0x1f, // jz next
0x4c,0x8b,0xcd, // mov r9,rbp
0x44,0x8b,0x43,0x24, // mov r8d,[rbx+0x24]
0x48,0x8b,0xcf, // mov rcx,rdi
0x8b,0x90,0xc8,0x00,0x00,0x00, // mov edx,[rax+0xc8]
0x8b,0x43,0x20,0x48,0xc1,0xe2,0x20,0x48,0x0b,0xd0,0xe8,0x99,0xa5,0xff,0xff,
0x48,0x83,0xc3,0x30, // next: add rbx,0x30
0x48,0x3b,0xde,0x75,0xce}; // cmp rbx,rsi; jne loop (two bytes earlier)
// The jz sits at +8 and ends at +10; the entry's step is at +41.
static_assert(patch[9] == 41 - 10);
// The jne ends at +50 and the loop now starts at +0.
static_assert(static_cast<signed char>(patch[49]) == 0 - 50 && static_cast<signed char>(contract[49]) == 2 - 50);
}
+153 -10
View File
@@ -1,5 +1,7 @@
#pragma once
#include <algorithm>
#include <bit>
#include <array>
#include <cstdint>
#include <optional>
#include <string>
@@ -7,10 +9,71 @@
namespace dingosdk {
// XInput button bits, plus two synthetic trigger bits (above the dead zone).
inline constexpr std::uint32_t controller_button_mask = 0x3f3ff;
// Single keys retain their saved format. Chords pack up to five sorted key IDs
// into the remaining 30 bits of a distinct tag in the same profile field.
inline constexpr std::uint32_t keyboard_binding_tag = 0x80000000u;
inline constexpr std::uint32_t keyboard_chord_tag = 0x40000000u;
// Yes and No in a server's vote until the player binds them otherwise: F1 and F2.
inline constexpr std::uint32_t default_vote_yes_binding = keyboard_binding_tag | 0x70u, default_vote_no_binding = keyboard_binding_tag | 0x71u;
inline constexpr std::uint8_t keyboard_key_id(std::uint32_t key) noexcept {
if (key >= '0' && key <= '9') return static_cast<std::uint8_t>(key - '0' + 1);
if (key >= 'A' && key <= 'Z') return static_cast<std::uint8_t>(key - 'A' + 11);
if (key == 0x20) return 37; // Space
if (key >= 0x70 && key <= 0x7b) return static_cast<std::uint8_t>(key - 0x70 + 38); // F1-F12
if (key == 0x11) return 50; // Ctrl
if (key == 0x10) return 51; // Shift
if (key == 0x12) return 52; // Alt
return 0;
}
inline constexpr std::uint8_t keyboard_key_from_id(std::uint32_t id) noexcept {
if (id >= 1 && id <= 10) return static_cast<std::uint8_t>('0' + id - 1);
if (id >= 11 && id <= 36) return static_cast<std::uint8_t>('A' + id - 11);
if (id == 37) return 0x20;
if (id >= 38 && id <= 49) return static_cast<std::uint8_t>(0x70 + id - 38);
if (id == 50) return 0x11;
if (id == 51) return 0x10;
if (id == 52) return 0x12;
return 0;
}
inline constexpr bool bindable_keyboard_key(std::uint32_t key) noexcept {
return keyboard_key_id(key) != 0;
}
inline constexpr bool keyboard_binding(std::uint32_t value) noexcept {
if ((value & ~0xffu) == keyboard_binding_tag) return bindable_keyboard_key(value & 0xffu);
if ((value & 0xc0000000u) != keyboard_chord_tag) return false;
unsigned count = 0, previous = 0;
bool ended = false;
for (unsigned slot = 0; slot < 5; ++slot) {
const unsigned id = (value >> (slot * 6)) & 0x3fu;
if (!id) { ended = true; continue; }
if (ended || !keyboard_key_from_id(id) || id <= previous) return false;
previous = id;
++count;
}
return count >= 2;
}
inline std::uint32_t keyboard_binding_from_keys(const std::array<std::uint64_t, 4>& keys) noexcept {
std::uint32_t value = keyboard_chord_tag;
unsigned count = 0;
for (unsigned id = 1; id <= 52; ++id) {
const auto key = keyboard_key_from_id(id);
if (!(keys[key / 64] & (std::uint64_t{1} << (key % 64)))) continue;
if (count == 5) return 0;
value |= id << (count++ * 6);
}
if (count == 1) {
const auto id = value & 0x3fu;
return keyboard_binding_tag | keyboard_key_from_id(id);
}
return count >= 2 ? value : 0;
}
inline constexpr bool valid_controller_combo(std::uint32_t value) noexcept {
return (value & ~controller_button_mask) == 0;
}
// Button names shown to the player. Bindings are always stored as XInput bits.
inline constexpr bool valid_action_binding(std::uint32_t value) noexcept {
return valid_controller_combo(value) || keyboard_binding(value);
}
// Button names shown to the player. Controller chords keep their XInput bits.
enum class ControllerStyle : std::uint8_t { xbox, dualshock4, dualsense };
struct ControllerInput {
bool available{};
@@ -19,16 +82,63 @@ struct ControllerInput {
// (a pad connecting, disconnecting or switching source) resets latches.
unsigned device{};
ControllerStyle style{};
std::array<std::uint64_t, 4> keys{};
};
// Switches a player can put on a button or key: each runs a console command when pressed.
// `key` is its name in the profile, `name` what "bind <name>" takes.
struct ActionBind {
std::string_view key, name, label, command;
};
inline constexpr std::array<ActionBind, 8> action_binds{{
{"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"},
{"time_of_day", "tod", "Next time of day", "tod next"},
{"no_bail", "nobail", "No Bail", "nobail toggle"},
{"challenges", "challenges", "Show / hide challenges", "challenges toggle"},
{"nametags", "nametags", "Player nametags", "mp nametags toggle"},
{"nametag_dots", "nametagdots", "Player dots", "mp nametag-dots toggle"},
}};
struct ControllerBindingsModel {
bool available{};
bool freecam_controller{};
std::uint32_t freecam_controller_combo{};
std::uint32_t freecam_combo{};
std::uint32_t tp_to_freecam_combo{};
std::uint32_t noclip_combo{};
std::uint32_t forward_velocity_combo{};
std::uint32_t up_velocity_combo{};
std::uint32_t offboard_up_velocity_combo{};
std::uint32_t vote_yes_combo{}, vote_no_combo{}; // answering a dedicated server's vote
std::array<std::uint32_t, action_binds.size()> action_combos{}; // by action_binds
std::string status;
};
inline std::string controller_combo_label(std::uint32_t value, ControllerStyle style = ControllerStyle::xbox) {
if (!value) return "Not bound";
if (keyboard_binding(value)) {
const auto key_name = [](std::uint32_t key) {
if (key == 0x20) return std::string("Space");
if (key == 0x11) return std::string("Ctrl");
if (key == 0x10) return std::string("Shift");
if (key == 0x12) return std::string("Alt");
if (key >= 0x70) return "F" + std::to_string(key - 0x6fu);
return std::string(1, static_cast<char>(key));
};
if ((value & 0xc0000000u) == keyboard_binding_tag) return key_name(value & 0xffu);
std::string label;
const auto append = [&](unsigned wanted) {
for (unsigned slot = 0; slot < 5; ++slot) {
const auto id = (value >> (slot * 6)) & 0x3fu;
if (!id) break;
if (id != wanted) continue;
if (!label.empty()) label += " + ";
label += key_name(keyboard_key_from_id(id));
}
};
for (unsigned id = 50; id <= 52; ++id) append(id);
for (unsigned id = 1; id < 50; ++id) append(id);
return label;
}
const bool playstation = style != ControllerStyle::xbox;
struct Button { std::uint32_t bit; const char* xbox; const char* playstation; };
constexpr Button buttons[]{
@@ -46,18 +156,34 @@ inline std::string controller_combo_label(std::uint32_t value, ControllerStyle s
return label;
}
// A held combo never repeats. Returning from menus, reconnecting, changing a
// binding, and changing level all require releasing every bound button first.
// binding, and changing level all require releasing the bound inputs first.
struct ControllerComboLatch {
std::uint32_t combo{};
unsigned device{};
bool armed{};
bool update(std::uint32_t binding, ControllerInput input, bool inhibited) noexcept {
if (binding != combo || input.device != device) {
combo = binding; device = input.device; armed = false;
const bool keyboard = keyboard_binding(binding);
const unsigned source = keyboard ? 0 : input.device;
if (binding != combo || source != device) {
combo = binding; device = source; armed = false;
}
if (inhibited || !input.available || !combo || !valid_controller_combo(combo)) {
if (inhibited || (!keyboard && !input.available) || !combo || !valid_action_binding(combo)) {
armed = false; return false;
}
if (keyboard) {
bool any = false, all = true;
for (unsigned slot = 0; slot < 5; ++slot) {
const auto key = (combo & 0xc0000000u) == keyboard_binding_tag ?
(slot ? 0u : combo & 0xffu) : keyboard_key_from_id((combo >> (slot * 6)) & 0x3fu);
if (!key) break;
const bool held = (input.keys[key / 64] & (std::uint64_t{1} << (key % 64))) != 0;
any |= held; all &= held;
}
if (!any) armed = true;
if (!armed || !all) return false;
armed = false;
return true;
}
const auto held = input.buttons & combo;
if (!held) armed = true;
if (!armed || held != combo) return false;
@@ -71,13 +197,30 @@ struct ControllerComboCapture {
std::uint32_t chord{};
unsigned device{};
bool ready{};
std::optional<std::uint32_t> update(ControllerInput input) noexcept {
if (!input.available || input.device != device) {
chord = 0; ready = false; device = input.device;
std::array<std::uint64_t, 4> keyboard_chord{};
std::optional<std::uint32_t> update(ControllerInput input, bool allow_keyboard = false) noexcept {
if (input.device != device || (!input.available && !allow_keyboard)) {
chord = 0; ready = false; keyboard_chord = {}; device = input.device;
}
if (!input.available && !allow_keyboard) return {};
const auto buttons = input.buttons & controller_button_mask;
const bool keys_held = std::any_of(input.keys.begin(), input.keys.end(), [](auto word) { return word != 0; });
if (!ready) { ready = buttons == 0 && (!allow_keyboard || !keys_held); return {}; }
if (allow_keyboard && keys_held && !chord) {
if (std::popcount(input.keys[0]) + std::popcount(input.keys[1]) +
std::popcount(input.keys[2]) + std::popcount(input.keys[3]) >
std::popcount(keyboard_chord[0]) + std::popcount(keyboard_chord[1]) +
std::popcount(keyboard_chord[2]) + std::popcount(keyboard_chord[3])) keyboard_chord = input.keys;
}
if (std::any_of(keyboard_chord.begin(), keyboard_chord.end(), [](auto word) { return word != 0; })) {
if (!keys_held) {
const auto result = keyboard_binding_from_keys(keyboard_chord);
keyboard_chord = {}; ready = false;
if (result) return result;
}
return {};
}
if (!input.available) return {};
const auto buttons = input.buttons & controller_button_mask;
if (!ready) { ready = buttons == 0; return {}; }
if (std::popcount(buttons) > std::popcount(chord)) chord = buttons;
if (!buttons && chord) {
const auto result = chord; chord = 0; ready = false;
@@ -1,4 +1,5 @@
#pragma once
#include <charconv>
#include <cstdint>
#include <optional>
#include <string_view>
@@ -27,4 +28,21 @@ inline std::optional<ObjectPlacement> parse_object_placement(std::string_view ar
: current == ObjectPlacement::host_only ? ObjectPlacement::nobody : ObjectPlacement::everyone;
return {};
}
// How many objects each player may have placed in a session, set by the host or the server:
// 0 is no limit beyond the protocol's own (max_object_limit). The host and a server's admins
// are not held to it. A player over it keeps what everyone already sees of theirs; only the
// objects past the limit are left out.
inline constexpr unsigned max_object_limit = 1024;
// What a server or a host starts with, until they choose otherwise.
inline constexpr unsigned default_object_limit = 100;
inline constexpr bool valid_object_limit(std::uint64_t value) noexcept { return value <= max_object_limit; }
// Accepts a number from 1 to max_object_limit, and off|none|0 for no limit.
inline std::optional<unsigned> parse_object_limit(std::string_view argument) noexcept {
if (argument == "off" || argument == "none") return 0U;
unsigned value{};
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
if (argument.empty() || parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !valid_object_limit(value))
return {};
return value;
}
} // namespace dingosdk
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <algorithm>
#include <string_view>
// Which of a dedicated server's commands a role may use (Server/roles.h): asked by the server
// before it runs one, and by the game to show a player only what their role gives them.
namespace dingosdk {
// The settings menu's names for some commands, and the one each is.
inline std::string_view canonical_role_command(std::string_view verb) noexcept {
return verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement" : verb == "object-limit" ? "objects"
: verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip" : verb == "nobail-allow" ? "nobail"
: verb == "boosts-allow" ? "boosts" : verb == "tuning-enforce" ? "tuning" : verb;
}
// Commands "every command" does not give: who has which role, and who has a slot kept for them.
inline bool role_listed_only(std::string_view command) noexcept { return command == "role" || command == "reserved"; }
// What every role with any command may also use.
inline bool role_common_command(std::string_view command) noexcept {
return command.empty() || command == "help" || command == "roles";
}
// `all`: the role has every command; `commands`: the ones it lists.
template<class Commands> bool role_allows(bool all, const Commands &commands, std::string_view command) noexcept {
command = canonical_role_command(command);
const bool named = std::find(commands.begin(), commands.end(), command) != commands.end();
if (named || (all && !role_listed_only(command))) return true;
return role_common_command(command) && (all || commands.begin() != commands.end());
}
} // namespace dingosdk
+75 -6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "distance_settings.h"
#include "object_placement.h"
#include "role_commands.h"
#include "session_limits.h"
#include "tick_settings.h"
#include "voice_settings.h"
@@ -37,6 +38,10 @@ struct MultiplayerLobby {
// A dedicated server the ReSkate team runs (developer_identity.h): first in the browser.
bool official{};
int ping = -1; // ms, when the server answered directly
// A dedicated server that takes direct connections: its address (host byte order) and UDP
// port, 0 for none.
std::uint32_t direct_ip{};
int direct_port{};
// Steam friends skating there now, by name (steam_social.h).
std::vector<std::string> friends;
};
@@ -97,12 +102,16 @@ struct MultiplayerChatLine {
std::string name, text;
bool local{}; // sent by this player
// The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none)
// and a tag shown in a box before the name ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or empty).
// and a tag shown in a box before the name ("Dev", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend", "Server" or empty).
std::uint32_t color{};
std::string tag;
// With the chat filter on, `text` is masked and this is the line as sent (same length), so
// the overlay can keep emote names the filter caught; empty when nothing was masked.
std::string unmasked;
bool server{}; // said by the dedicated server itself: its console, welcome or answers
// The colour of the line's own text when it is not the usual one (IM_COL32 layout, 0 = usual):
// a dedicated server's lines, in the colour its owner chose.
std::uint32_t text_color{};
};
// A command typed into chat with a leading "/" (shown as the player types "/").
struct MultiplayerChatCommand {
@@ -112,12 +121,34 @@ struct MultiplayerChatCommand {
std::string argument; // what Tab completes after it: "player", "map", "time" or ""
};
// What the chat overlay reads each frame: cheap to copy, unlike the full model.
// The vote a dedicated server is running or has just finished, for the card above chat.
struct MultiplayerVote {
std::uint32_t id{}; // 0: none
std::string label; // "change the map to ..."
unsigned yes{}, no{}, needed{}, seconds{}; // seconds: left of a running one
std::uint8_t outcome{}; // 0 running, 1 passed, 2 failed, 3 cancelled
std::uint8_t mine{}; // this player's answer: 0 none yet, 1 yes, 2 no; in a poll, 1 + the answer's index
bool may_vote{}; // not the player a kick vote is about
std::uint32_t yes_bind{}, no_bind{}; // the player's binds for Yes and No (controller_bindings.h), 0: none
// A poll: a question (`label`) with answers and a count for each, instead of yes and no.
bool poll{};
std::vector<std::string> answers;
std::vector<unsigned> counts;
};
// A dedicated server's announcement, for its card. `id` 0: none showing.
struct MultiplayerAnnouncement {
std::uint32_t id{};
std::string text;
unsigned seconds{}; // how long it still shows
};
struct MultiplayerChat {
bool available{}; // in a session that can carry chat
std::uint64_t latest{}; // sequence of the newest line, 0 when empty
std::vector<MultiplayerChatLine> lines;
std::vector<MultiplayerChatCommand> commands; // what "/" offers in this session
std::vector<std::string> players, maps; // what their arguments complete to
MultiplayerVote vote;
MultiplayerAnnouncement announcement;
};
// A player this PC's lobbies never admit, kept in the local profile.
struct MultiplayerBan {
@@ -125,11 +156,18 @@ struct MultiplayerBan {
std::string name; // as they were known when banned; may be empty
std::int64_t added{}; // Unix time
};
inline constexpr float player_distance_least = 50.f, player_distance_unlimited = 1000.f;
struct MultiplayerModel {
VoiceModel voice;
std::vector<MultiplayerBan> bans;
unsigned tps = multiplayer_default_tps;
ObjectPlacement object_placement = ObjectPlacement::everyone;
// Objects each player may have placed in this session (0: no limit), the limit this
// player is held to (0 for the host and a server's admins), and how many they have placed.
unsigned object_limit{}, object_limit_own{}, objects_placed{};
// This player may resize the objects they place (a dedicated server can turn it off for its
// players; its admins always may).
bool object_scaling{true};
// Host setting: whether guests may use noclip / No Bail (the host and server admins always may).
bool guest_noclip{true}, guest_no_bail{true}, guest_boosts{true};
// Host setting: guests skate with the host's physics tuning (on a dedicated server: the
@@ -138,12 +176,24 @@ struct MultiplayerModel {
std::string tuning_status;
// Local display preference: the floating name label above each peer.
bool nametags{true};
// Nametag style: ReSkate's own (name, distance, role colours, dots) or the game's.
bool custom_nametags{true};
// Local: whether session text chat shows at all (and T opens it).
bool chat_visible{true};
// Local: bad words in chat names and messages show as **** (on by default).
bool chat_filter{true};
// Local, for ReSkate's nametags: `nametag_distance` is how far away a player's name still
// shows (past it they are a dot), `nametag_dots` whether those dots and the ones at the
// screen's edge for off-screen players show at all, `nametags_friends` names only the
// player's Steam friends.
// Local: how far away another player still gets a skater built for them, in metres. Past
// it they keep their nametag or dot. Each skater costs memory and frame time, in view or
// not. `player_distance_unlimited` and above: every player, however far.
float player_distance{120.f};
// Local: connect straight to dedicated servers that offer it (their "connection": "direct")
// instead of through Steam's relays. Off: always the relays, and no server sees this PC's address.
bool prefer_direct{true};
float nametag_distance{120.f};
bool nametag_dots{true};
bool nametags_friends{};
// Local: chat bubbles above each skater's head. `chat_bubbles_distance` is how far away
// a player may be and still show one, `chat_bubbles_duration` how many seconds a line
// stays before it fades, `chat_bubbles_history` how many recent lines stack up.
@@ -153,9 +203,12 @@ struct MultiplayerModel {
float chat_bubbles_distance{40.f};
float chat_bubbles_duration{5.f};
int chat_bubbles_history{3};
// Local: the tag the ReSkate backend gives this player ("Dev", "Staff", "Creator", "Centrix" or "Homie"; empty
// Local: the tag the ReSkate backend gives this player ("Dev", "Staff", "Content Creator", "Centrix" or "Homie"; empty
// for most players) and its role colour, and whether they show it, and the animated items
// that come with it, to everyone.
// The local player is on the ReSkate team's developer, staff or homie list: the player list
// gives each player's Steam ID.
bool steam_ids_shown{};
std::string identity_tag;
std::uint32_t identity_tag_colour{};
bool identity_tag_shown{true}, identity_items_shown{true};
@@ -175,7 +228,14 @@ struct MultiplayerModel {
float voice_range = default_voice_range; // how far the host (or server) forwards proximity voice
// In a dedicated server's session: the server is the host but not a player.
// Admins it lists may change its settings, and its map through Levels.
// server_admin: the player's role there has at least one of the server's commands.
bool dedicated{}, server_admin{};
// Their role on it (empty: none), the tag it wears, whether it has every command or the
// ones listed, and what it gives beyond commands (the protocol's role_* bits).
std::string server_role, server_role_tag;
bool server_all_commands{};
std::vector<std::string> server_commands;
std::uint32_t server_permissions{};
// `bans` is the dedicated server's list (for its admins) rather than this PC's.
bool server_bans{};
unsigned server_ban_total{};
@@ -201,9 +261,14 @@ struct MultiplayerModel {
bool party_leader{}, party_open{}, parties{};
std::vector<MultiplayerPartyInvite> party_invites; // newest last
bool active{}, hosting{}, connected{}, echo{}, remote_visible{}, local_ready{};
// The session is connected but waiting on its map, which this PC does not have and is
// being asked about or downloaded: not yet a session the player is in.
bool map_fetching{};
std::uint64_t local_id{}, peer_id{}, sent{}, received{}, dropped{}, pose_updates{}, board_pose_updates{};
std::size_t skater_bones{}, board_bones{};
std::string invite, map;
// The code of the session this player is in, as host or guest (`invite` is a host's own).
std::string join_code;
std::string status = "Multiplayer is off.";
std::string native_status;
std::string cosmetic_status;
@@ -224,8 +289,12 @@ struct MultiplayerModel {
std::uint64_t queue_us{}, skipped_updates{}, send_failures{}, invalid_messages{}, sent_bytes{},
raw_sent_bytes{};
};
// Whether the player's role on the dedicated server they are on has one of its commands.
inline bool server_can(const MultiplayerModel& model, std::string_view command) noexcept {
return model.dedicated && role_allows(model.server_all_commands, model.server_commands, command);
}
inline bool multiplayer_controls_level(const MultiplayerModel& model) noexcept {
// A server admin's level choice is sent to the server, which moves everyone.
return model.lobby_joining || (model.active && !model.hosting && !model.echo && !model.server_admin);
// The level choice of a player who may change the server's map is sent to the server, which moves everyone.
return model.lobby_joining || (model.active && !model.hosting && !model.echo && !server_can(model, "map"));
}
} // namespace dingosdk
+6
View File
@@ -18,4 +18,10 @@ constexpr bool valid_pose_interval(std::uint32_t interval) noexcept {
if (interval == multiplayer_pose_interval(rate)) return true;
return false;
}
// The most poses a second one player is sent on a dedicated server, whatever the crowd
// around them (Extension/Multiplayer/Session/room.h, crowd_limits); 0: no limit.
inline constexpr unsigned crowd_pose_budget = 600, min_crowd_budget = 300, max_crowd_budget = 20000;
constexpr bool valid_crowd_budget(unsigned budget) noexcept {
return !budget || (budget >= min_crowd_budget && budget <= max_crowd_budget);
}
} // namespace dingosdk
@@ -0,0 +1,47 @@
// A draw packet's bones: unpacked and placed in the world.
#include "Engine/Game/Rendering/draw_packet.h"
#include <cmath>
#include <iostream>
#include <limits>
#include <stdexcept>
using namespace dingosdk;
using namespace dingosdk::draw_packet;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
bool near(float a, float b) { return std::abs(a - b) < 1e-4f; }
void a_packed_bone_unpacks() {
// Turned 90 degrees about +Z (x -> y), at (1, 2, 3): the columns of that matrix, as packed.
const PackedBone packed{0, -1, 0, 1, 1, 0, 0, 2, 0, 0, 1, 3};
const auto transform = unpack(packed);
const auto point = game::place({1, 0, 0}, transform);
check(near(point[0], 1) && near(point[1], 3) && near(point[2], 3), "the rows come back from the columns");
}
void bones_are_placed_by_root_and_actor() {
const PackedBone origin{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0};
const LinearTransform actor{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {10, 0, 0, 1}}};
LinearTransform out{};
check(skin(origin, placement({100, 200, 300, 0}, false, actor), out) && near(out[3][0], 100) && near(out[3][2], 300),
"the root adds the whole metres back");
check(skin(origin, placement({1, 0, 0, 0}, true, actor), out), "relative");
const auto point = game::place({0, 0, 0}, out);
check(near(point[0], 10) && near(point[1], 1), "a relative packet then goes into its actor's transform");
PackedBone bad = origin;
bad[5] = std::numeric_limits<float>::quiet_NaN();
check(!skin(bad, placement({}, false, actor), out), "a bone that is not finite is refused");
}
}
int main() {
try {
a_packed_bone_unpacks();
bones_are_placed_by_root_and_actor();
} catch (const std::exception& error) {
std::cerr << "FAILED: " << error.what() << '\n';
return 1;
}
std::cout << "Draw packet tests passed.\n";
return 0;
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include "Engine/Game/Abi/linear_transform.h"
#include <array>
#include <cmath>
#include <cstddef>
// A skinned mesh's draw packet as the renderer hands it out (Engine/Game/Build/20260929/
// skater_render.h): its bones' skinning matrices, packed, and how to place them in the world.
// Plain data and math; Extension/HallOfMeat/hall_of_meat_render.h reads it.
namespace dingosdk::draw_packet {
using game::LinearTransform;
// A packed bone: the skinning matrix's three columns of four floats, transposed for the shader.
using PackedBone = std::array<float, 12>;
constexpr LinearTransform unpack(const PackedBone& p) noexcept {
return {{{p[0], p[4], p[8], 0}, {p[1], p[5], p[9], 0}, {p[2], p[6], p[10], 0}, {p[3], p[7], p[11], 1}}};
}
// Where the packed bones go: the packet's root (the world position's whole metres the packing left
// out) and, for a relative packet, its actor's transform after it.
constexpr LinearTransform placement(const std::array<float, 4>& root, bool relative, const LinearTransform& actor) noexcept {
const LinearTransform at_root{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {root[0], root[1], root[2], 1}}};
return relative ? game::compose(at_root, actor) : at_root;
}
// The bone's skinning matrix in the world. False for one that is not finite.
inline bool skin(const PackedBone& packed, const LinearTransform& placed, LinearTransform& out) noexcept {
for (const float value : packed)
if (!std::isfinite(value) || std::abs(value) > 1e7f) return false;
out = game::compose(unpack(packed), placed);
return true;
}
}
@@ -0,0 +1,26 @@
// The step length the game keeps for a simulation rate, and the rate back from it.
#include "Engine/Game/Settings/simulation_time.h"
#include <bit>
#include <cstdint>
#include <iostream>
#include <limits>
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; }
}
} // namespace
int main() {
using namespace dingosdk::simulation_time;
check(std::bit_cast<std::uint32_t>(step_seconds(60)) == 0x3c888888, "60 a second: the game's 1/60, from whole nanoseconds");
check(step_seconds(200) == 0.005f, "200 a second: 5 ms");
check(step_seconds(0) == 0.0f, "no rate, no step");
check(rate_of(step_seconds(60)) == 60 && rate_of(step_seconds(200)) == 200 && rate_of(step_seconds(1200)) == 1200,
"the rate back from its step");
check(rate_of(0.0f) == 0 && rate_of(std::numeric_limits<float>::quiet_NaN()) == 0, "no step, no rate");
if (failures) return 1;
std::cout << "Simulation time tests passed.\n";
return 0;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <cmath>
#include <cstdint>
// skate.'s simulation clock as plain numbers. It steps its simulation at a fixed rate
// (SimulationTime.ForceSimRate, 60 a second) on a clock its time scale slows (SimulationTime.TimeScale),
// each step as long as the rate gives: whole nanoseconds (1e9 / rate), then seconds (Ghidra
// sim_time_clock_apply_rate; measured 2026-10-09: 60 gives 0x3c888888, 200 gives 0.005). A skater
// takes its physics step length from it when its core is built (Ghidra build_pose_object).
namespace dingosdk::simulation_time {
// The length of one simulation step at `rate`, seconds, exactly as the game keeps it. 0 for no rate.
inline float step_seconds(std::uint32_t rate) noexcept {
return rate ? static_cast<float>(static_cast<double>(1000000000u / rate) * 1e-9) : 0.0f;
}
// The rate a step length was made at; 0 for none.
inline std::uint32_t rate_of(float step_seconds) noexcept {
return std::isfinite(step_seconds) && step_seconds > 0 ? static_cast<std::uint32_t>(std::lround(1.0 / step_seconds)) : 0;
}
}
@@ -0,0 +1,73 @@
#include "Engine/Game/Skater/first_person_cycle.h"
#include <cstdio>
#include <string_view>
// First person in the camera cycle (first_person::cycle_height_write): the game's camera height
// button flips UseHighCam; the option turns that into high -> low -> first person -> high.
namespace {
using namespace dingosdk::first_person;
int failures = 0;
void expect(bool condition, std::string_view what) {
if (condition) return;
++failures;
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
}
bool same(const CycleWrite& write, bool high, CycleStep step) { return write.high == high && write.step == step; }
void off() {
for (const bool first_person : {false, true})
for (const bool high : {false, true})
expect(same(cycle_height_write({false, first_person, true}, high), high, CycleStep::none),
"with the option off the game's height passes through and first person is left alone");
}
void steps() {
expect(same(cycle_height_write({true, false, true}, false), false, CycleStep::none), "high -> low is the game's own step");
expect(same(cycle_height_write({true, false, true}, true), false, CycleStep::enter),
"low -> first person keeps the low camera behind it");
expect(same(cycle_height_write({true, true, false}, true), true, CycleStep::leave),
"first person (low behind it) -> the high camera");
expect(same(cycle_height_write({true, true, false}, false), true, CycleStep::leave),
"first person switched on in the menu over the high camera still lands on high");
expect(same(cycle_height_write({true, false, false}, true), true, CycleStep::none),
"where first person cannot start (on foot, Freecam) low -> high is the game's own step");
}
// The game reads the height it keeps and writes the opposite on every press.
void presses() {
for (const bool start_high : {true, false}) {
bool high = start_high, first_person = false;
const auto press = [&] {
const auto write = cycle_height_write({true, first_person, !first_person}, !high);
high = write.high;
if (write.step == CycleStep::enter) first_person = true;
if (write.step == CycleStep::leave) first_person = false;
};
if (!start_high) press(); // low -> first person
if (!start_high) press(); // first person -> high
for (int round = 0; round < 3; ++round) {
expect(high && !first_person, "the cycle starts each round on the high camera");
press();
expect(!high && !first_person, "first press: low camera");
press();
expect(!high && first_person, "second press: first person over the low camera");
press();
}
expect(high && !first_person, "the third press of each round returns to the high camera");
}
}
} // namespace
int main() {
off();
steps();
presses();
if (failures) {
std::printf("%d first-person cycle test(s) failed\n", failures);
return 1;
}
std::printf("first-person cycle tests passed\n");
return 0;
}
@@ -0,0 +1,162 @@
#include "Engine/Game/Skater/first_person_spring.h"
#include <cstdio>
#include <string_view>
// True first person (first_person::stabilize): animated head wobble in, a level and
// steady view out, while spins, flips and teleports still come through.
namespace {
using namespace dingosdk::first_person;
constexpr float pi = std::numbers::pi_v<float>, deg = pi / 180;
int failures = 0;
void expect(bool condition, std::string_view what) {
if (condition) return;
++failures;
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
}
// A head in the game's row layout (right, up, backward, position).
Matrix head_at(float yaw, float pitch, float roll, Vec3 position) {
Matrix m{};
m[15] = 1;
write_level(m, yaw, pitch, roll);
for (unsigned i = 0; i < 3; ++i) m[12 + i] = position[i];
return m;
}
struct Angles { float yaw, pitch, roll; };
Angles angles_of(const Matrix& m) {
const Vec3 forward{-m[8], -m[9], -m[10]};
Angles a{std::atan2(forward[0], forward[2]), std::asin(std::clamp(forward[1], -1.0f, 1.0f)), 0};
Matrix level{};
write_level(level, a.yaw, a.pitch, 0);
const Vec3 right{m[0], m[1], m[2]};
a.roll = std::atan2(dot(right, {level[4], level[5], level[6]}), dot(right, {level[0], level[1], level[2]}));
return a;
}
void frame_layout() {
const auto a = angles_of(head_at(0.7f, -0.3f, 0.2f, {}));
expect(std::abs(a.yaw - 0.7f) < 1e-4f && std::abs(a.pitch + 0.3f) < 1e-4f && std::abs(a.roll - 0.2f) < 1e-4f,
"write_level round trips yaw, pitch and roll");
const auto m = head_at(0.7f, -0.3f, 0.2f, {});
const auto backward = cross({m[0], m[1], m[2]}, {m[4], m[5], m[6]});
expect(std::abs(backward[0] - m[8]) + std::abs(backward[1] - m[9]) + std::abs(backward[2] - m[10]) < 1e-4f,
"right x up = backward, as the game's camera rows");
}
// Cruising straight with the head nodding, tilting, swaying and bobbing.
void wobble() {
const Settings settings;
Spring spring;
float roll{}, nod{}, sway{}, bob{};
double t = 0;
for (int i = 0; i < 600; ++i, t += 1.0 / 60) {
const auto w = static_cast<float>(t);
const Vec3 origin{w * 5, 0, 0};
const auto head = head_at(1.0f + 6 * deg * std::sin(2 * pi * 2.0f * w),
-10 * deg + 12 * deg * std::sin(2 * pi * 2.5f * w), 10 * deg * std::sin(2 * pi * 3.0f * w),
{origin[0], 1.6f + 0.04f * std::sin(2 * pi * 2 * w), 0});
const auto view = update(spring, head, settings, t, origin);
if (i < 120) continue;
const auto a = angles_of(view);
roll = std::max(roll, std::abs(a.roll));
nod = std::max(nod, std::abs(a.pitch + 10 * deg * settings.head_pitch * 0.01f));
sway = std::max(sway, std::abs(wrap_angle(a.yaw - 1.0f)));
bob = std::max(bob, std::abs(view[13] - 1.6f));
}
expect(roll < 0.01f * deg, "head tilt 0% keeps the horizon level");
expect(nod < 12 * deg * settings.head_pitch * 0.01f * 0.5f, "the kept head nod loses over half its wobble");
expect(sway < 3 * deg, "side-to-side sway loses half its wobble");
expect(bob < 0.025f, "head bob is reduced");
}
void spin() {
const Settings settings;
Spring spring;
float lag{};
double t = 0;
for (int i = 0; i < 120; ++i, t += 1.0 / 60) {
const float yaw = 4 * pi * static_cast<float>(t); // two turns a second
const auto view = update(spring, head_at(wrap_angle(yaw), -5 * deg, 0, {0, 1.6f, 0}), settings, t, {});
if (i > 30) lag = std::max(lag, std::abs(wrap_angle(angles_of(view).yaw - yaw)));
}
expect(lag < 30 * deg, "a 720 keeps the view within 30 degrees");
}
void ollie() {
const Settings settings;
Spring spring;
float gap{};
double t = 0;
for (int i = 0; i < 90; ++i, t += 1.0 / 60) {
const float w = static_cast<float>(t) - 0.5f;
const float rise = w < 0 ? 0 : w < 0.2f ? 0.5f * w / 0.2f : w < 0.6f ? 0.5f : std::max(0.0f, 0.5f - (w - 0.6f) * 2.5f);
const auto view = update(spring, head_at(0, 0, 0, {0, 1.6f + rise, 0}), settings, t, {});
if (w > 0.45f && w < 0.6f) gap = std::max(gap, std::abs(view[13] - 1.6f - rise));
}
expect(gap < 0.05f, "the eye catches up by the top of an ollie");
}
void snapping() {
const Settings settings;
Spring spring;
auto view = update(spring, head_at(2.0f, 0, 0, {0, 1.6f, 0}), settings, 1.0, {});
expect(std::abs(wrap_angle(angles_of(view).yaw - 2.0f)) < 1e-3f, "the first frame takes the head as it is");
view = update(spring, head_at(-1.0f, 0, 0, {500, 1.6f, 0}), settings, 2.0, {500, 0, 0});
expect(std::abs(wrap_angle(angles_of(view).yaw + 1.0f)) < 1e-3f && std::abs(view[12] - 500) < 1e-3f,
"a pause or teleport snaps instead of smoothing across it");
}
// One full backflip: with Follow flips the view turns over, without it stays upright.
void flips() {
Settings follow, upright;
upright.follow_flips = false;
Spring a, b;
float lowest_follow = 1, lowest_upright = 1;
bool finite = true;
double t = 0;
for (int i = 0; i < 90; ++i, t += 1.0 / 60) {
const float turn = std::min(1.0f, static_cast<float>(t) / 1.2f) * 2 * pi;
Matrix head{};
head[15] = 1;
write(head, multiply({0, std::sin(0.5f), 0, std::cos(0.5f)}, {std::sin(-turn / 2), 0, 0, std::cos(-turn / 2)}), {0, 1.6f, 0});
const auto va = update(a, head, follow, t, {}), vb = update(b, head, upright, t, {});
for (unsigned j = 0; j < 16; ++j) finite = finite && std::isfinite(va[j]) && std::isfinite(vb[j]);
lowest_follow = std::min(lowest_follow, va[5]);
lowest_upright = std::min(lowest_upright, vb[5]);
}
expect(finite, "a flip gives finite views");
expect(lowest_follow < -0.5f, "Follow flips turns the view over");
expect(lowest_upright > 0.1f, "without Follow flips the view stays upright");
}
void off_and_limits() {
Settings settings;
settings.stabilize = false;
Spring spring;
const auto head = head_at(0.3f, 0.2f, 0.4f, {1, 2, 3});
const auto view = update(spring, head, settings, 1.0, {});
float difference = 0;
for (unsigned i = 0; i < 16; ++i) difference += std::abs(view[i] - head[i]);
expect(difference < 1e-4f, "True first person off is the plain head camera");
expect(valid(Settings{}), "the defaults are valid");
settings.smoothing = 101;
expect(!valid(settings), "smoothing above 100% is refused");
}
} // namespace
int main() {
frame_layout();
wobble();
spin();
ollie();
snapping();
flips();
off_and_limits();
if (failures) {
std::printf("%d first-person stabilize test(s) failed\n", failures);
return 1;
}
std::printf("first-person stabilize tests passed\n");
return 0;
}
@@ -0,0 +1,57 @@
#include "Engine/Game/Skater/velocity_boost.h"
#include <cstdlib>
#include <iostream>
#include <limits>
namespace {
void check(bool condition, const char* message) {
if (!condition) { std::cerr << message << '\n'; std::exit(1); }
}
bool near(float a, float b) { return std::abs(a - b) < .001f; }
}
int main() {
using dingosdk::VelocityBoostDirection;
using dingosdk::velocity_boost_delta;
constexpr auto forward = VelocityBoostDirection::forward;
constexpr auto up = VelocityBoostDirection::up;
std::array<float, 16> transform{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
// Rotate through world headings; changing preferred stance does not change
// this contract: facing-aligned travel boosts forward, fakie travel reverses it.
for (int heading = 0; heading < 360; heading += 15) {
const float angle = static_cast<float>(heading) * 3.14159265f / 180.f;
transform[8] = std::sin(angle);
transform[10] = std::cos(angle);
for (const float speed : {1.f, 75.f, 300.f}) {
for (const float travel : {12.f, -12.f}) {
const std::array<float, 3> motion{travel * transform[8], 0, travel * transform[10]};
const auto delta = velocity_boost_delta(transform, speed, forward, motion);
check(delta.has_value(), "Valid riding boost rejected");
const float sign = travel < 0 ? -1.f : 1.f;
check(near((*delta)[0], sign * speed * transform[8]) && near((*delta)[2], sign * speed * transform[10]),
"Boost opposes riding direction");
check(near(std::hypot((*delta)[0], (*delta)[2]), speed), "Configured boost magnitude changed");
}
}
}
transform[8] = 0; transform[10] = 1;
for (const auto motion : {std::array<float, 3>{0, 0, 0}, {0, 0, -.05f}, {12, 0, 0}, {0, -50, 0}, {0, 50, 0}}) {
const auto delta = velocity_boost_delta(transform, 75, forward, motion);
check(delta && near((*delta)[2], 75), "Stationary, sideways or vertical motion reversed boost");
}
// Preserve the original three-dimensional facing axis and magnitude on ramps.
transform[9] = .6f; transform[10] = .8f;
const auto ramp = velocity_boost_delta(transform, 75, forward, {0, -100, -12});
check(ramp && near((*ramp)[1], -45) && near((*ramp)[2], -60), "Fakie ramp direction or magnitude changed");
const auto rising = velocity_boost_delta(transform, 75, forward, {0, -100, 12});
check(rising && near((*rising)[1], 45) && near((*rising)[2], 60), "Vertical motion overrode forward travel");
const auto upward = velocity_boost_delta(transform, 25, up, {0, -100, -12});
check(upward && *upward == std::array<float, 3>{0, 25, 0}, "Up Boost changed in fakie");
for (const float speed : {0.f, 301.f, std::numeric_limits<float>::quiet_NaN()})
check(!velocity_boost_delta(transform, speed, forward), "Invalid forward speed accepted");
check(!velocity_boost_delta(transform, 26, up), "Invalid up speed accepted");
check(!velocity_boost_delta({}, 75, forward), "Degenerate facing accepted");
check(!velocity_boost_delta(transform, 75, forward, {0, 0, std::numeric_limits<float>::infinity()}),
"Invalid travel velocity accepted");
std::cout << "Velocity boost regressions passed.\n";
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <cstdint>
namespace dingosdk::first_person {
// "First person in the camera cycle". With the game's Camera height control on, its camera
// height button (R3) writes the profile option UseHighCam as the opposite of the height it reads
// (true is the high camera). With the option on, ReSkate turns that into high -> low -> first
// person -> high by changing the value written: the step into first person keeps the low camera
// behind it, and the step out of first person always lands on the high camera.
enum class CycleStep : std::uint8_t { none, enter, leave };
struct CycleState {
bool enabled{}; // the option is on and the local skater is in control
bool first_person{}; // first person has the camera
bool can_enter{}; // first person can take the camera now: on the board, no Freecam
};
struct CycleWrite {
bool high{}; // the height the game keeps
CycleStep step{}; // what first person does
};
constexpr CycleWrite cycle_height_write(const CycleState& state, bool high) noexcept {
if (!state.enabled) return {high, CycleStep::none};
if (state.first_person) return {true, CycleStep::leave};
if (high && state.can_enter) return {false, CycleStep::enter};
return {high, CycleStep::none};
}
}
+113 -3
View File
@@ -13,12 +13,30 @@ struct Settings {
bool enabled = false;
Vec3 offset{}, rotation{};
float up = 50, down = 50, left = 50, right = 50;
// True first person: the view takes its heading from the head but keeps the
// horizon level, and filters the animation's wobble. Percentages, 0..100.
bool stabilize = true, follow_flips = true;
// Hand the view back to the game's camera while the skater walks (opt-in).
bool board_only = false;
// Put first person into the game's camera height cycle (first_person_cycle.h, opt-in).
bool camera_cycle = false;
float smoothing = 60, head_pitch = 86, head_roll = 0, bob = 25;
};
// Adaptive low-pass ("One Euro") state for one channel: heavy smoothing while the
// value barely moves, little lag while it moves fast (spins, carving).
struct Euro { float value{}, rate{}; };
struct Stable {
bool ready = false;
double time{};
Euro yaw, pitch, roll;
std::array<Euro, 3> offset{};
};
struct Spring {
bool ready = false;
double time{};
Vec3 position{}, velocity{}, angular_velocity{}, previous_target{}, previous_origin{};
Quat rotation{0, 0, 0, 1};
Stable stable{};
};
inline Vec3 add(const Vec3& a, const Vec3& b) { return {a[0]+b[0], a[1]+b[1], a[2]+b[2]}; }
inline Vec3 subtract(const Vec3& a, const Vec3& b) { return {a[0]-b[0], a[1]-b[1], a[2]-b[2]}; }
@@ -70,10 +88,101 @@ inline bool valid(const Settings& settings) {
const auto bounded = [](float v, float limit) { return std::isfinite(v) && std::abs(v) <= limit; };
for (unsigned i = 0; i < 3; ++i)
if (!bounded(settings.offset[i], offset_limit) || !bounded(settings.rotation[i], rotation_limit)) return false;
for (float v : {settings.up, settings.down, settings.left, settings.right})
for (float v : {settings.up, settings.down, settings.left, settings.right,
settings.smoothing, settings.head_pitch, settings.head_roll, settings.bob})
if (!bounded(v, strength_limit) || v < 0) return false;
return true;
}
inline float dot(const Vec3& a, const Vec3& b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
inline Vec3 cross(const Vec3& a, const Vec3& b) {
return {a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]};
}
inline float wrap_angle(float radians) { return std::remainder(radians, 2*std::numbers::pi_v<float>); }
inline float euro_alpha(float cutoff, float seconds) {
const float tau = 1 / (2*std::numbers::pi_v<float>*cutoff);
return 1 / (1 + tau/seconds);
}
// `change` is the raw step since the last sample (already wrapped for angles).
// The rate is filtered at 0.5 Hz so an animation's back-and-forth averages out
// and only sustained turns (spins, carves) open the filter up.
inline void euro(Euro& state, float change, float seconds, float min_cutoff, float beta) {
state.rate += (change/seconds - state.rate) * euro_alpha(0.5f, seconds);
state.value += change * euro_alpha(min_cutoff + beta*std::abs(state.rate), seconds);
}
// Rows (right, up, backward) of a level view looking along yaw / pitch, then
// rolled by `roll` about the view axis. World is Y-up; yaw 0 looks along +Z.
inline void write_level(Matrix& m, float yaw, float pitch, float roll) {
const Vec3 forward{std::sin(yaw)*std::cos(pitch), std::sin(pitch), std::cos(yaw)*std::cos(pitch)};
const Vec3 level_right{-std::cos(yaw), 0, std::sin(yaw)};
const auto level_up = cross(level_right, forward);
const float c = std::cos(roll), s = std::sin(roll);
for (unsigned i = 0; i < 3; ++i) {
m[i] = level_right[i]*c + level_up[i]*s;
m[4+i] = level_up[i]*c - level_right[i]*s;
m[8+i] = -forward[i];
}
}
// Splits the head into heading, nod and tilt, keeps the heading, scales the nod
// and tilt by the settings and filters all three, so the view stops wobbling
// with the animation. Flips and bails (head upside down) hand the view back to
// the real head when follow_flips is on.
inline Matrix stabilize(Stable& state, const Matrix& head, const Settings& settings, double now, const Vec3& origin) {
const Vec3 right{head[0], head[1], head[2]}, up{head[4], head[5], head[6]};
const Vec3 forward{-head[8], -head[9], -head[10]};
const float level = std::sqrt(forward[0]*forward[0] + forward[2]*forward[2]);
// Looking nearly straight up or down, the top of the head says where the
// skater is heading better than the face does.
const float yaw = level > 0.25f ? std::atan2(forward[0], forward[2])
: forward[1] < 0 ? std::atan2(up[0], up[2]) : std::atan2(-up[0], -up[2]);
const float pitch = std::asin(std::clamp(forward[1], -1.0f, 1.0f));
Matrix level_frame = head;
write_level(level_frame, yaw, pitch, 0);
const float roll = std::atan2(dot(right, {level_frame[4], level_frame[5], level_frame[6]}),
dot(right, {level_frame[0], level_frame[1], level_frame[2]}));
const float pitch_target = pitch * settings.head_pitch * 0.01f;
const float roll_target = roll * settings.head_roll * 0.01f;
const Vec3 eye{head[12], head[13], head[14]};
const auto offset = subtract(eye, origin);
const double elapsed = now - state.time;
bool snap = !state.ready || !std::isfinite(elapsed) || elapsed < 0 || elapsed > 0.25;
for (unsigned i = 0; i < 3 && !snap; ++i) snap = std::abs(offset[i] - state.offset[i].value) > 3;
if (snap) {
state = {true, now, {yaw, 0}, {pitch_target, 0}, {roll_target, 0},
{Euro{offset[0], 0}, Euro{offset[1], 0}, Euro{offset[2], 0}}};
} else if (elapsed > 0.00001) {
state.time = now;
const float seconds = static_cast<float>(elapsed);
// Smoothing 0% follows the head directly; 100% leaves a 0.15 Hz floor.
const float s = settings.smoothing * 0.01f;
const float angle_cutoff = s <= 0 ? 1000.0f : 0.15f * std::pow(40.0f, 1 - s);
constexpr float angle_beta = 0.6f;
euro(state.yaw, wrap_angle(yaw - state.yaw.value), seconds, angle_cutoff, angle_beta);
state.yaw.value = wrap_angle(state.yaw.value);
euro(state.pitch, pitch_target - state.pitch.value, seconds, angle_cutoff, angle_beta);
euro(state.roll, wrap_angle(roll_target - state.roll.value), seconds, angle_cutoff, angle_beta);
for (unsigned i = 0; i < 3; ++i) euro(state.offset[i], offset[i] - state.offset[i].value, seconds, 0.6f, 1.5f);
}
auto result = head;
write_level(result, state.yaw.value, state.pitch.value, state.roll.value);
// Head bob: the share of the eye's movement around the body that is kept.
const float bob = settings.bob * 0.01f;
for (unsigned i = 0; i < 3; ++i) {
const float smooth = state.offset[i].value;
result[12+i] = origin[i] + smooth + (offset[i] - smooth) * bob;
}
// Upside down (up.y below 0.4, fully past -0.1): blend to the real head.
const float flip = settings.follow_flips ? std::clamp((0.4f - up[1]) / 0.5f, 0.0f, 1.0f) : 0.0f;
if (flip > 0) {
auto stable = orientation(result);
auto real = orientation(head);
if (stable[0]*real[0] + stable[1]*real[1] + stable[2]*real[2] + stable[3]*real[3] < 0) for (auto& v : real) v = -v;
Quat blended{};
for (unsigned i = 0; i < 4; ++i) blended[i] = stable[i] + (real[i] - stable[i]) * flip;
write(result, normalized(blended), {result[12], result[13], result[14]});
}
return result;
}
inline Matrix target(Matrix head, const Settings& settings) {
const auto head_rotation = orientation(head);
const auto position = add({head[12], head[13], head[14]},
@@ -113,14 +222,15 @@ inline void damp(float& error, float& velocity, float strength, float seconds) {
}
inline Matrix update(Spring& spring, const Matrix& head, const Settings& settings, double now, const Vec3& origin = {}) {
if (!valid(settings) || !std::isfinite(now)) return head;
auto result = target(head, settings);
if (!settings.stabilize) spring.stable = {};
auto result = target(settings.stabilize ? stabilize(spring.stable, head, settings, now, origin) : head, settings);
const auto position = subtract({result[12],result[13],result[14]}, origin);
const auto rotation = orientation(result);
const double elapsed = now - spring.time;
if (!spring.ready || !settings.enabled || elapsed < 0 || elapsed > 0.25 ||
length(subtract(position, spring.previous_target)) > 5 ||
length(subtract(origin, spring.previous_origin)) > 5) {
spring = {true, now, position, {}, {}, position, origin, rotation};
spring = {true, now, position, {}, {}, position, origin, rotation, spring.stable};
return result;
}
spring.time = now;
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "Engine/Game/Abi/linear_transform.h"
#include <array>
#include <cstddef>
#include <cstdint>
// The skater's body as the physics simulates it (Engine/Game/Build/20260929/skater_body.h):
// 24 rigid bodies, the ragdoll, and the contacts of each physics step. Plain data;
// Extension/HallOfMeat/hall_of_meat_skater.h reads it.
//
// The bodies by the physics bone id the contact processing reports. The ids follow the
// order of the game's physics bone name map (Engine/Game/Build/20260929/skater_body.h). The
// game confirms it twice: the contact code's feet-on-board test picks exactly 15, 16, 19 and
// 20, and the skeleton publisher maps each body to the animation joint of its name
// (rig+0x1ae0). Bone 0 is the board's root, never a body hit. The ragdoll has no head body of
// its own: NECK1, the upper neck, is the one the head rides on, so it is shown as the head.
namespace dingosdk::skater_body {
enum class Bone : std::uint8_t {
board_root, neck1, neck, left_hand, left_forearm, left_arm, left_shoulder,
right_hand, right_forearm, right_arm, right_shoulder, spine3, spine2, spine1, spine,
left_toe, left_foot, left_leg, left_upleg, right_toe, right_foot, right_leg, right_upleg, hips,
};
inline constexpr std::size_t count = 24;
inline constexpr std::array<const char*, count> names{
"Board", "Head", "Neck", "Left hand", "Left forearm", "Left upper arm", "Left collarbone",
"Right hand", "Right forearm", "Right upper arm", "Right collarbone", "Upper chest", "Chest",
"Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh",
"Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis"};
constexpr std::size_t index(Bone bone) noexcept { return static_cast<std::size_t>(bone); }
// The feet touch the ground and the board all the time; their contacts do not tell
// whether a body is still tumbling.
constexpr bool foot(Bone bone) noexcept {
return bone == Bone::left_toe || bone == Bone::left_foot || bone == Bone::right_toe || bone == Bone::right_foot;
}
using game::Vec3;
// What a body's hardest contact in one physics step hit, by the kind the game gives the other
// side. Kinds 5 and 11 are the game's, still to be named.
struct HitKinds {
bool board{}, vehicle{}, world{}; // world: the ground, walls, rails and the rest of the static world
bool kind_5{}, kind_11{};
};
// One body in one physics step: whether it touches something, and its hardest contact. Bodies
// only have contacts while the ragdoll simulates them (a bail): not while riding or walking.
struct BodyContact {
bool touching{}; // a contact in this step
float impact{}; // the hardest contact's speed along its normal, metres per second
Vec3 slide{}; // its normal x relative velocity: the length is the speed along the surface
HitKinds hit;
};
// The whole body in one physics step.
struct Contacts {
std::array<BodyContact, count> bodies{};
};
}
+12 -1
View File
@@ -7,7 +7,8 @@ namespace dingosdk {
enum class VelocityBoostDirection { forward, up };
inline std::optional<std::array<float, 3>> velocity_boost_delta(
const std::array<float, 16>& transform, float speed, VelocityBoostDirection direction) noexcept {
const std::array<float, 16>& transform, float speed, VelocityBoostDirection direction,
const std::array<float, 3>& board_velocity = {}) noexcept {
const float maximum = direction == VelocityBoostDirection::up ? 25.0f : 300.0f;
if (!std::isfinite(speed) || speed < 1.0f || speed > maximum) return {};
if (direction == VelocityBoostDirection::up) return std::array<float, 3>{0, speed, 0};
@@ -17,6 +18,16 @@ inline std::optional<std::array<float, 3>> velocity_boost_delta(
if (!std::isfinite(length) || length <= .01f) return {};
const auto scale = speed / std::sqrt(length);
for (auto& component : delta) component *= scale;
// Fakie travel opposes the skater's facing axis. Use horizontal motion so
// falling/rising cannot reverse a boost, and retain facing at rest or sideways.
const float horizontal_length = std::hypot(delta[0], delta[2]);
if (horizontal_length > .01f) {
const float travel = (delta[0] * board_velocity[0] + delta[2] * board_velocity[2]) / horizontal_length;
if (!std::isfinite(travel)) return {};
// Ignore tiny backwards drift around standstill (0.1 world units/s).
if (travel < -.1f)
for (auto& component : delta) component = -component;
}
return delta;
}
}
@@ -0,0 +1,56 @@
#include "Engine/Game/World/park_randomization.h"
#include <iostream>
#include <map>
#include <set>
namespace {
int failures{};
void check(bool condition, const char* message) {
if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; }
}
}
int main() {
using namespace dingosdk;
std::mt19937 generator{12345};
std::array<std::map<std::string, unsigned>, park_lots.size()> seen;
constexpr unsigned rolls = 30000;
for (unsigned roll = 0; roll < rolls; ++roll) {
const auto choices = random_park_choices(generator);
for (unsigned lot = 0; lot < choices.size(); ++lot) {
check(!choices[lot].empty() && choices[lot] != "empty" && valid_park(lot, choices[lot]),
"random layouts are non-empty and supported by their lot");
++seen[lot][choices[lot]];
}
}
for (unsigned lot = 0; lot < park_lots.size(); ++lot) {
std::set<std::string> expected;
for (unsigned family = 0; family < park_families.size(); ++family)
for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant)
expected.insert(park_id(family, variant));
check(seen[lot].size() == expected.size(), "every supported layout can be selected");
const auto average = rolls / static_cast<unsigned>(expected.size());
for (const auto& layout : expected) {
const auto count = seen[lot][layout];
// Broad deterministic bound catches equal-family weighting without
// making this a fragile statistical test or fixing a particular RNG sequence.
check(count > average * 3 / 4 && count < average * 5 / 4,
"layouts have equal weight regardless of family size");
}
}
ParkLaunchRandomization launch;
check(!launch.consume(true, false), "launch randomization is off by default");
launch.pending = true;
check(!launch.consume(false, false) && launch.pending, "wait for park content before consuming launch");
check(launch.consume(true, false) && !launch.pending, "randomize once when the controller becomes ready");
check(!launch.consume(false, false) && !launch.consume(true, false), "map reloads do not reroll");
launch.pending = true;
check(!launch.consume(true, true) && !launch.pending, "guests never randomize host-controlled parks");
check(!launch.consume(true, false), "leaving a host does not defer startup randomization");
launch.pending = true;
check(!launch.consume(false, true) && !launch.pending,
"joining a host cancels startup randomization before park content is ready");
check(!launch.consume(true, false), "leaving an unready host session cannot trigger a deferred roll");
if (failures) return 1;
std::cout << "park randomization: ok\n";
return 0;
}
+2 -2
View File
@@ -86,9 +86,9 @@ void exact_fields(const Json& object, std::initializer_list<std::string_view> ex
CustomLevelManifest parse(std::string_view text) {
if (text.size() > custom_level_manifest_max_bytes)
throw std::runtime_error("custom-level manifest exceeds 64 KiB");
if (text.starts_with("\xef\xbb\xbf"))
throw std::runtime_error("custom-level manifest must not contain a UTF-8 BOM");
// Json::parse skips a byte-order mark, which several Windows editors still
// write. The launcher and the server accept one, so the game must too.
const auto root = Json::parse(text, JsonLimits{
custom_level_manifest_max_bytes, 8, 8192});
exact_fields(root, {"schema", "levels"}, "custom-level manifest");
+1 -1
View File
@@ -23,7 +23,7 @@ struct CustomLevelManifest {
std::string issue;
};
// Parse the Studio-authored Patch/dingosdk-levels.json contract. Invalid input
// Parse a mod's or the Patch folder's reskate-levels.json. Invalid input
// fails closed: no partial set of destinations is returned.
CustomLevelManifest parse_custom_level_manifest(std::string_view text) noexcept;
CustomLevelManifest read_custom_level_manifest(const std::filesystem::path& path) noexcept;
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "park_rotation.h"
#include <random>
#include <utility>
namespace dingosdk {
// Sample layouts, rather than families, so every authored layout has equal weight.
// Empty lots are deliberately excluded from the random pool.
template<class Generator> ParkChoices random_park_choices(Generator& generator) {
ParkChoices choices;
for (unsigned lot = 0; lot < park_lots.size(); ++lot) {
unsigned count{};
for (const auto variants : park_lots[lot].counts) count += variants;
auto selected = std::uniform_int_distribution<unsigned>(0, count - 1)(generator);
for (unsigned family = 0; family < park_families.size(); ++family) {
const auto variants = park_lots[lot].counts[family];
if (selected < variants) { choices[lot] = park_id(family, selected + 1); break; }
selected -= variants;
}
}
return choices;
}
inline ParkChoices random_park_choices() {
thread_local std::mt19937 generator{std::random_device{}()};
return random_park_choices(generator);
}
struct ParkLaunchRandomization {
bool pending{};
// Waiting for content does not consume the launch. Joining a host does:
// leaving that session or reconstructing the controller must not reroll.
bool consume(bool ready, bool controlled_by_host) {
if (controlled_by_host) { pending = false; return false; }
if (!ready || !std::exchange(pending, false)) return false;
return true;
}
};
}
+1
View File
@@ -49,6 +49,7 @@ inline std::string park_label(std::string_view id) {
}
struct ParksModel {
bool available{}, ready{}, controlled_by_host{};
bool randomize_on_launch{};
ParkChoices choices;
std::string feedback;
};
+33
View File
@@ -1,5 +1,6 @@
#pragma once
#include "world_layer_catalog.h"
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
@@ -12,6 +13,38 @@
namespace dingosdk {
// Layers ReSkate keeps switched off and does not offer: the seasons' content on San Vansterdam
// (seasons 5 and 6) and the Isle of Grom (season 5). They keep their rows in the catalog, which
// players and servers share by position, so nothing about a session's layer list changes; the
// game just treats each as "off" whatever is saved or sent for it (local_world_layers.cpp),
// and no menu or console command names it.
inline constexpr std::array<std::string_view, 8> world_layers_kept_off{
"bam_dtn_season_05_content", "bam_dtn_season_05_inactive", "bam_dtn_season_05_permacontent",
"bam_dev_seasonal_content_season_06", "bam_dtn_season_06_content", "bam_dtn_season_06_inactive",
"bam_dtn_season_06_permacontent", "grom_season_05_content"};
inline bool world_layer_kept_off(std::string_view key) noexcept {
for (const auto kept : world_layers_kept_off)
if (kept == key) return true;
return false;
}
// A layer's name as menus show it: its label without the developers' own marks, the words
// DEV and DTN ("DTN Season 04 Content" is "Season 04 Content").
inline std::string world_layer_title(std::string_view label) {
std::string title;
for (std::size_t at = 0; at < label.size();) {
const auto end = std::min(label.find(' ', at), label.size());
const auto word = label.substr(at, end - at);
at = end + 1;
const bool mark = word.size() == 3 && (word[0] == 'D' || word[0] == 'd') &&
(((word[1] == 'E' || word[1] == 'e') && (word[2] == 'V' || word[2] == 'v')) ||
((word[1] == 'T' || word[1] == 't') && (word[2] == 'N' || word[2] == 'n')));
if (mark || word.empty()) continue;
if (!title.empty()) title += ' ';
title += word;
}
return title.empty() ? std::string(label) : title;
}
inline constexpr std::array<std::string_view, 3> world_layer_modes{"default", "on", "off"};
// One choice per catalog row (world_layers()), in catalog order.
using WorldLayerChoices = std::vector<std::string>;
+134
View File
@@ -0,0 +1,134 @@
// The skinned MeshSet and SkeletonAsset readers, on made-up resources.
#include "Engine/Resource/mesh_set.h"
#include "Engine/Resource/skeleton_asset.h"
#include <cstring>
#include <iostream>
#include <memory>
#include <stdexcept>
using namespace dingosdk::frostbite;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
template <class T> void put(std::vector<std::byte>& data, std::size_t offset, T value) {
if (data.size() < offset + sizeof(T)) data.resize(offset + sizeof(T));
std::memcpy(data.data() + offset, &value, sizeof(T));
}
void pointer(std::vector<std::byte>& data, std::size_t offset, std::size_t target) {
put<std::uint64_t>(data, offset, target - 16);
}
// One level of detail, one section of three vertices on palette {5, 7}, one triangle.
struct Made {
std::vector<std::byte> resource, geometry;
};
Made made_mesh(std::uint8_t second_weight = 55) {
Made m;
auto& r = m.resource;
r.resize(1100);
const std::array<std::uint32_t, 3> header{208, 188, 384};
for (std::size_t i = 0; i < header.size(); ++i) put<std::uint32_t>(r, i * 4, header[i]);
put<std::uint8_t>(r, 124, 1);
put<std::uint16_t>(r, 180, 1);
pointer(r, 48, 256);
put<std::uint32_t>(r, 256, 1); // kind
put<std::uint32_t>(r, 256 + 8, 1); // one section
pointer(r, 256 + 12, 512);
put<std::uint32_t>(r, 256 + 84, 33); // 16-bit indices
put<std::uint32_t>(r, 256 + 88, 6); // index bytes
put<std::uint32_t>(r, 256 + 92, 108); // vertex bytes: 3 x 36
put<std::uint8_t>(r, 256 + 116, 0x42); // the chunk's guid
pointer(r, 512 + 16, 1024); // palette
put<std::uint16_t>(r, 512 + 24, 2);
put<std::uint8_t>(r, 512 + 31, 3); // triangle list
put<std::uint32_t>(r, 512 + 32, 1);
put<std::uint32_t>(r, 512 + 44, 3);
const std::size_t d = 512 + 112;
const std::array<std::array<std::uint8_t, 4>, 5> elements{{{1, 3, 0, 0}, {2, 23, 12, 0}, {3, 23, 20, 0}, {4, 13, 28, 0}, {5, 13, 32, 0}}};
for (std::size_t i = 0; i < elements.size(); ++i)
for (std::size_t j = 0; j < 4; ++j) put<std::uint8_t>(r, d + i * 4 + j, elements[i][j]);
put<std::uint8_t>(r, d + 64, 36);
put<std::uint8_t>(r, d + 96, 5);
put<std::uint8_t>(r, d + 97, 1);
put<std::uint16_t>(r, 1024, 5);
put<std::uint16_t>(r, 1026, 7);
auto& g = m.geometry;
for (std::size_t v = 0; v < 3; ++v) {
const auto at = v * 36;
put<std::array<float, 3>>(g, at, {static_cast<float>(v), 0.0f, 0.0f});
put<std::array<std::uint16_t, 4>>(g, at + 12, {0, 1, 0, 0});
put<std::array<std::uint16_t, 4>>(g, at + 20, {0, 0, 0, 0});
put<std::array<std::uint8_t, 4>>(g, at + 28, {200, second_weight, 0, 0});
put<std::array<std::uint8_t, 4>>(g, at + 32, {0, 0, 0, 0});
}
put<std::array<std::uint16_t, 3>>(g, 108, {0, 1, 2});
return m;
}
void a_skinned_lod_reads() {
const auto m = made_mesh();
const auto lod = read_mesh_lod(m.resource, 0);
check(lod.vertex_bytes == 108 && lod.index_bytes == 6 && lod.chunk != Guid{}, "the level's buffers and chunk");
const auto mesh = read_skinned_mesh(m.resource, 0, m.geometry);
check(mesh.vertices.size() == 3 && mesh.triangles == std::vector<std::uint32_t>{0, 1, 2}, "three vertices, one triangle");
const auto& v = mesh.vertices[2];
check(v.position[0] == 2.0f && v.bones[0] == 5 && v.bones[1] == 7 && v.weights[0] == 200 && v.weights[1] == 55,
"positions, and bones through the palette");
}
void four_influences_are_enough() {
auto m = made_mesh();
m.resource[512 + 112 + 96] = std::byte{3}; // only position, the first bones and weights:
const std::array<std::array<std::uint8_t, 4>, 3> elements{{{1, 3, 0, 0}, {2, 23, 12, 0}, {4, 13, 28, 0}}};
for (std::size_t i = 0; i < elements.size(); ++i)
for (std::size_t j = 0; j < 4; ++j) put<std::uint8_t>(m.resource, 512 + 112 + i * 4 + j, elements[i][j]);
const auto mesh = read_skinned_mesh(m.resource, 0, m.geometry);
check(mesh.vertices[1].bones[1] == 7 && mesh.vertices[1].weights[1] == 55 && mesh.vertices[1].weights[4] == 0,
"a coarse level of detail weighs four bones");
}
void a_bad_mesh_is_refused() {
const auto broken = made_mesh(54);
bool refused{};
try { (void)read_skinned_mesh(broken.resource, 0, broken.geometry); } catch (const std::runtime_error&) { refused = true; }
check(refused, "weights that do not add up");
refused = false;
try { (void)read_mesh_lod(broken.resource, 1); } catch (const std::runtime_error&) { refused = true; }
check(refused, "a level of detail it does not have");
}
ebx::Value object(std::vector<std::pair<std::string, ebx::Value>> fields) {
auto made = std::make_shared<ebx::Object>();
for (auto& [name, value] : fields) made->fields.push_back({0, std::move(name), std::move(value)});
return {made};
}
void a_skeleton_reads() {
const auto asset = object({
{"BoneNames", {ebx::Value::Array{{std::string("Root")}, {std::string("Hand")}}}},
{"Hierarchy", {ebx::Value::Array{{std::int64_t{-1}}, {std::int64_t{0}}}}},
});
const auto skeleton = read_skeleton(*std::get<std::shared_ptr<ebx::Object>>(asset.data));
check(skeleton.names == std::vector<std::string>{"Root", "Hand"} && skeleton.parents == std::vector<int>{-1, 0}, "names and parents");
const auto backwards = object({
{"BoneNames", {ebx::Value::Array{{std::string("A")}, {std::string("B")}}}},
{"Hierarchy", {ebx::Value::Array{{std::int64_t{1}}, {std::int64_t{-1}}}}},
});
bool refused{};
try { (void)read_skeleton(*std::get<std::shared_ptr<ebx::Object>>(backwards.data)); } catch (const std::runtime_error&) { refused = true; }
check(refused, "a parent after its child");
}
}
int main() {
try {
a_skinned_lod_reads();
four_influences_are_enough();
a_bad_mesh_is_refused();
a_skeleton_reads();
} catch (const std::exception& error) {
std::cerr << "FAILED: " << error.what() << '\n';
return 1;
}
std::cout << "Skinned mesh tests passed.\n";
return 0;
}
+2
View File
@@ -167,6 +167,8 @@ BinaryBundle read_binary_bundle(const std::span<const std::byte> data) {
const auto metadataOffset = reader.u32(endian);
const auto metadataSize = reader.u32(endian);
if (strings > data.size()) throw std::out_of_range("Binary bundle string table is out of range");
const auto tables = std::uint64_t{ebxCount} * 28 + std::uint64_t{resourceCount} * 56 + std::uint64_t{chunkCount} * 44; // SHA-1 plus entry bytes
if (tables > reader.remaining()) throw std::out_of_range("Binary bundle asset counts exceed the file");
std::vector<Sha1> hashes(total);
for (auto& hash : hashes) hash = reader.sha1();
+84 -49
View File
@@ -127,56 +127,89 @@ private:
std::unordered_map<std::uint32_t, std::string> cache_;
};
// Appends `path` to the pool and a full/leaf row pair for it in `bundle`. A
// leaf name another preset already answers to stays that preset's: the table
// holds one row per hash, and mods number their presets alike (1_ap, 2_ap ...)
// in different folders. Returns that preset's path, empty when the leaf row
// went in too.
std::string insert(std::vector<std::byte>& data, std::vector<std::byte>& meta, const std::string& path, std::uint32_t bundle) {
const auto table = Reader(data, meta).out;
const auto full = hash(path), short_name = hash(leaf(path));
const auto held = [&](std::uint64_t wanted) {
return std::ranges::any_of(table.rows, [&](const Row& row) { return row.hash == wanted; });
};
if (held(full)) throw std::runtime_error(path + " collides with an existing lookup");
// A path with no folder is its own leaf and has the one row.
const bool with_leaf = full != short_name && !held(short_name);
std::string holder;
if (full != short_name && !with_leaf) {
holder = "another lookup";
for (const auto& preset : table.presets)
if (hash(leaf(preset.first)) == short_name) { holder = preset.first; break; }
// A table being added to: read once, each preset added in memory, written once. (Reading
// the whole table again for every preset made a merge of many cosmetic mods quadratic.)
class Builder {
public:
Builder(std::span<const std::byte> base, const Parsed& parsed)
: base_(base), table_(parsed), rows_(parsed.rows), size_(base.size()) {
for (const auto& row : rows_) hashes_.insert(row.hash);
// In path order, so each leaf name keeps the first preset that answers to it.
for (const auto& preset : parsed.presets) leaves_.emplace(hash(leaf(preset.first)), preset.first);
}
if (path.size() > 255) throw std::runtime_error(path + " is too long for a lookup");
const auto growth = (4 + path.size() + 15) & ~std::size_t{15};
const auto offset = table.bundles - table.pool;
if (offset > 0x7FFFFF) throw std::runtime_error("bundle-reference path pool is full");
const auto token = static_cast<std::uint32_t>(offset) | 0x800000U | (static_cast<std::uint32_t>(path.size()) << 24);
const auto bundles = table.bundles + growth, lookups = table.lookups + growth;
const auto payload = table.payload + growth + (with_leaf ? 32 : 16);
std::vector<std::byte> out(payload + 20);
std::copy_n(data.begin(), table.bundles, out.begin());
put32(out, table.bundles, none);
std::memcpy(out.data() + table.bundles + 4, path.data(), path.size());
std::copy(data.begin() + static_cast<std::ptrdiff_t>(table.bundles), data.begin() + static_cast<std::ptrdiff_t>(table.lookups),
out.begin() + static_cast<std::ptrdiff_t>(bundles));
auto rows = table.rows;
rows.push_back({full, bundle, token});
if (with_leaf) rows.push_back({short_name, bundle, token});
std::ranges::sort(rows, {}, &Row::hash);
for (std::size_t i = 0; i < rows.size(); ++i) {
put64(out, lookups + i * 16, rows[i].hash);
put32(out, lookups + i * 16 + 8, rows[i].bundle);
put32(out, lookups + i * 16 + 12, rows[i].token);
// Appends `path` to the pool and a full/leaf row pair for it in `bundle`. A
// leaf name another preset already answers to stays that preset's: the table
// holds one row per hash, and mods number their presets alike (1_ap, 2_ap ...)
// in different folders. Returns that preset's path, empty when the leaf row
// went in too.
std::string insert(const std::string& path, std::uint32_t bundle) {
// What reading the table back at this point would refuse.
if (size_ > maximum_bytes) throw std::runtime_error("not a bundle-reference table");
if (rows_.size() > 200000) throw std::runtime_error("bundle-reference table counts differ");
const auto full = hash(path), short_name = hash(leaf(path));
if (hashes_.contains(full)) throw std::runtime_error(path + " collides with an existing lookup");
// A path with no folder is its own leaf and has the one row.
const bool with_leaf = full != short_name && !hashes_.contains(short_name);
std::string holder;
if (full != short_name && !with_leaf) {
const auto found = leaves_.find(short_name);
holder = found != leaves_.end() ? found->second : std::string("another lookup");
}
if (path.size() > 255) throw std::runtime_error(path + " is too long for a lookup");
const auto growth = (4 + path.size() + 15) & ~std::size_t{15};
const auto offset = table_.bundles + pool_.size() - table_.pool;
if (offset > 0x7FFFFF) throw std::runtime_error("bundle-reference path pool is full");
const auto token = static_cast<std::uint32_t>(offset) | 0x800000U | (static_cast<std::uint32_t>(path.size()) << 24);
const auto at = pool_.size();
pool_.resize(at + growth);
put32(pool_, at, none);
std::memcpy(pool_.data() + at + 4, path.data(), path.size());
rows_.push_back({full, bundle, token});
hashes_.insert(full);
if (with_leaf) {
rows_.push_back({short_name, bundle, token});
hashes_.insert(short_name);
}
// The preset a later one with this leaf name is told about: the first in path order.
if (const auto [held, fresh] = leaves_.emplace(short_name, path); !fresh && path < held->second) held->second = path;
size_ += growth + (with_leaf ? 32 : 16);
changed_ = true;
return holder;
}
std::copy(data.begin() + static_cast<std::ptrdiff_t>(table.payload), data.end(), out.begin() + static_cast<std::ptrdiff_t>(payload));
put64(out, 8, lookups);
put64(out, 24, bundles);
put32(out, 72, static_cast<std::uint32_t>(rows.size()));
put32(meta, 0, static_cast<std::uint32_t>(payload));
data = std::move(out);
return holder;
}
// The table as it now is; untouched when nothing was added.
void write(std::vector<std::byte>& data, std::vector<std::byte>& meta) {
if (!changed_) return;
const auto bundles = table_.bundles + pool_.size(), lookups = table_.lookups + pool_.size();
const auto payload = lookups + rows_.size() * 16;
std::vector<std::byte> out(payload + 20);
std::copy_n(base_.begin(), table_.bundles, out.begin());
std::copy(pool_.begin(), pool_.end(), out.begin() + static_cast<std::ptrdiff_t>(table_.bundles));
std::copy(base_.begin() + static_cast<std::ptrdiff_t>(table_.bundles), base_.begin() + static_cast<std::ptrdiff_t>(table_.lookups),
out.begin() + static_cast<std::ptrdiff_t>(bundles));
std::ranges::sort(rows_, {}, &Row::hash);
for (std::size_t i = 0; i < rows_.size(); ++i) {
put64(out, lookups + i * 16, rows_[i].hash);
put32(out, lookups + i * 16 + 8, rows_[i].bundle);
put32(out, lookups + i * 16 + 12, rows_[i].token);
}
std::copy(base_.begin() + static_cast<std::ptrdiff_t>(table_.payload), base_.end(), out.begin() + static_cast<std::ptrdiff_t>(payload));
put64(out, 8, lookups);
put64(out, 24, bundles);
put32(out, 72, static_cast<std::uint32_t>(rows_.size()));
put32(meta, 0, static_cast<std::uint32_t>(payload));
data = std::move(out);
}
private:
std::span<const std::byte> base_;
const Parsed& table_;
std::vector<Row> rows_;
std::unordered_set<std::uint64_t> hashes_;
std::unordered_map<std::uint64_t, std::string> leaves_; // a leaf name's hash -> the preset that answers to it
std::vector<std::byte> pool_; // the path descriptors added after the base's
std::size_t size_{}; // the table's size in bytes with what was added
bool changed_{};
};
} // namespace
bool is_table(std::string_view name) {
@@ -191,6 +224,7 @@ MergedTable merge(const Table& base, std::span<const Table> edits) {
const std::span<const std::byte> base_bundles = base.resource.subspan(original.bundles, original.lookups - original.bundles);
MergedTable result{{base.resource.begin(), base.resource.end()}, {base.resourceMeta.begin(), base.resourceMeta.end()}};
std::map<std::string, std::uint32_t> added;
Builder builder(base.resource, original);
for (std::size_t index = 0; index < edits.size(); ++index) {
const auto& edit = edits[index];
const auto copy = Reader(edit.resource, edit.resourceMeta).out;
@@ -209,12 +243,13 @@ MergedTable merge(const Table& base, std::span<const Table> edits) {
if (found->second != bundle) ++result.conflicts;
continue;
}
if (auto holder = insert(result.resource, result.resourceMeta, path, bundle); !holder.empty())
if (auto holder = builder.insert(path, bundle); !holder.empty())
result.shadowed.push_back({index, path, std::move(holder)});
added.emplace(path, bundle);
++result.added;
}
}
builder.write(result.resource, result.resourceMeta);
return result;
}
+208
View File
@@ -0,0 +1,208 @@
#include "mesh_set.h"
#include <cmath>
#include <cstring>
#include <stdexcept>
#include <string>
namespace dingosdk::frostbite {
namespace {
// The resource: three layout words this reader knows, the skinned flag, the levels of detail
// (a table of pointers) and their count. A pointer counts from byte 16.
constexpr std::array<std::uint32_t, 4> known_header{208, 188, 384, 0};
constexpr std::size_t skinned_flag = 124, lod_count = 180, lod_table = 48, pointer_base = 16;
// A level of detail: its kind (1), its sections, the index format (33: 16-bit indices), the
// buffer sizes and the geometry chunk.
constexpr std::size_t lod_kind = 0, section_count = 8, section_table = 12, index_format = 84, index_bytes = 88,
vertex_bytes = 92, geometry_chunk = 116;
constexpr std::uint32_t known_lod_kind = 1, sixteen_bit_indices = 33;
// A section, 384 bytes: its bone palette, primitive type (3: a triangle list), triangles, first
// index, where its vertices start in the vertex buffer and how many, and its vertex declaration.
constexpr std::size_t section_size = 384, palette = 16, palette_count = 24, primitive = 31, triangle_count = 32,
first_index = 36, vertex_start = 40, vertex_count = 44, declaration = 112;
constexpr std::uint8_t triangle_list = 3;
// The declaration: 16 elements of {usage, format, offset, stream}, then {stride, classification}
// per stream, then the element and stream counts. Four bone influences a vertex, or eight with
// the second pair (the coarser levels of detail leave it out).
constexpr std::size_t stream_table = 64, element_total = 96, stream_total = 97;
enum Usage : std::uint8_t { position = 1, bones_low = 2, bones_high = 3, weights_low = 4, weights_high = 5 };
constexpr std::array<std::uint8_t, 6> usage_format{0, 3, 23, 23, 13, 13}; // float3, ushort4, ushort4, ubyte4n, ubyte4n
constexpr std::array<std::uint8_t, 6> usage_size{0, 12, 8, 8, 4, 4};
void require(bool valid, const char* message) {
if (!valid) throw std::runtime_error(std::string("MeshSet: ") + message);
}
struct Reader {
std::span<const std::byte> data;
template <class T> T at(std::size_t offset) const {
require(offset <= data.size() && sizeof(T) <= data.size() - offset, "it is truncated");
T value{};
std::memcpy(&value, data.data() + offset, sizeof(T));
return value;
}
std::size_t pointer(std::size_t offset) const {
const auto value = at<std::uint64_t>(offset);
require(value && value <= data.size() - pointer_base, "a pointer leads outside it");
return static_cast<std::size_t>(value) + pointer_base;
}
};
struct Attribute {
std::size_t base{}, stride{}, offset{};
bool present{};
};
}
MeshLod read_mesh_lod(std::span<const std::byte> resource, std::size_t lod) {
const Reader r{resource};
for (std::size_t i = 0; i < known_header.size(); ++i)
require(r.at<std::uint32_t>(i * 4) == known_header[i], "its layout is not the one this reader knows");
require(r.at<std::uint8_t>(skinned_flag) == 1, "it is not skinned");
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);
require(r.at<std::uint32_t>(at + lod_kind) == known_lod_kind, "the level of detail is of another kind");
require(r.at<std::uint32_t>(at + index_format) == sixteen_bit_indices, "its indices are not 16-bit");
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);
require(result.chunk != Guid{} && result.vertex_bytes > 0 && result.index_bytes > 0, "the level of detail is empty");
return result;
}
std::size_t mesh_lod_count(std::span<const std::byte> resource) {
const Reader r{resource};
for (std::size_t i = 0; i < known_header.size(); ++i)
require(r.at<std::uint32_t>(i * 4) == known_header[i], "its layout is not the one this reader knows");
return r.at<std::uint16_t>(lod_count);
}
MeshLod mesh_lod_geometry(std::span<const std::byte> resource, std::size_t lod) {
require(lod < mesh_lod_count(resource), "it has no such level of detail");
const Reader r{resource};
const auto at = r.pointer(lod_table + lod * 8);
require(r.at<std::uint32_t>(at + lod_kind) == known_lod_kind, "the level of detail is of another kind");
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);
require(result.chunk != Guid{} && result.vertex_bytes > 0, "the level of detail is empty");
return result;
}
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) {
const auto layout = mesh_lod_geometry(resource, lod);
const Reader r{resource}, g{geometry};
require(geometry.size() >= layout.vertex_bytes, "the geometry chunk is too short");
const auto at = r.pointer(lod_table + lod * 8);
const auto sections = r.at<std::uint32_t>(at + section_count);
require(sections <= 4096, "it has too many sections");
const auto table = r.pointer(at + section_table);
std::vector<std::array<float, 3>> result;
for (std::uint32_t section = 0; section < sections; ++section) {
const auto s = table + section * section_size;
const std::size_t count = r.at<std::uint32_t>(s + vertex_count);
if (!count) continue;
const auto d = s + declaration;
const auto elements = r.at<std::uint8_t>(d + element_total), streams = r.at<std::uint8_t>(d + stream_total);
require(elements <= 16 && streams > 0 && streams <= 16, "a vertex declaration is malformed");
// Each stream's vertices follow the one before, from where the section starts.
std::array<std::size_t, 16> bases{}, strides{};
std::size_t end = r.at<std::uint32_t>(s + vertex_start);
for (std::size_t stream = 0; stream < streams; ++stream) {
strides[stream] = r.at<std::uint8_t>(d + stream_table + stream * 2);
require(strides[stream] > 0, "a vertex stream has no size");
bases[stream] = end;
end += strides[stream] * count;
}
require(end <= layout.vertex_bytes, "a section's vertices run past the vertex buffer");
bool found{};
for (std::size_t element = 0; element < elements && !found; ++element) {
if (r.at<std::uint8_t>(d + element * 4) != position) continue;
const auto stream = r.at<std::uint8_t>(d + element * 4 + 3);
const std::size_t offset = r.at<std::uint8_t>(d + element * 4 + 2);
require(r.at<std::uint8_t>(d + element * 4 + 1) == usage_format[position] && stream < streams &&
offset + usage_size[position] <= strides[stream], "its positions have another format");
for (std::size_t vertex = 0; vertex < count; ++vertex)
result.push_back(g.at<std::array<float, 3>>(bases[stream] + vertex * strides[stream] + offset));
found = true;
}
require(found, "a section has no positions");
}
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};
require(geometry.size() >= std::size_t{layout.vertex_bytes} + layout.index_bytes, "the geometry chunk is too short");
const auto at = r.pointer(lod_table + lod * 8);
const auto sections = r.at<std::uint32_t>(at + section_count);
const auto table = r.pointer(at + section_table);
SkinnedMesh result;
for (std::uint32_t section = 0; section < sections; ++section) {
const auto s = table + section * section_size;
require(r.at<std::uint8_t>(s + primitive) == triangle_list, "a section is not a triangle list");
const auto count = r.at<std::uint32_t>(s + vertex_count), triangles = r.at<std::uint32_t>(s + triangle_count);
const auto bones = r.pointer(s + palette);
const auto bone_count = r.at<std::uint16_t>(s + palette_count);
const auto d = s + declaration;
const auto elements = r.at<std::uint8_t>(d + element_total), streams = r.at<std::uint8_t>(d + stream_total);
require(elements <= 16 && streams > 0 && streams <= 16, "a vertex declaration is malformed");
std::array<std::size_t, 16> bases{}, strides{};
std::size_t end = r.at<std::uint32_t>(s + vertex_start);
for (std::size_t stream = 0; stream < streams; ++stream) {
strides[stream] = r.at<std::uint8_t>(d + stream_table + stream * 2);
require(strides[stream] > 0 && r.at<std::uint8_t>(d + stream_table + stream * 2 + 1) == 0,
"a vertex stream is not per vertex");
bases[stream] = end;
end += strides[stream] * count;
}
require(end <= layout.vertex_bytes, "a section's vertices run past the vertex buffer");
std::array<Attribute, 6> attributes{};
for (std::size_t element = 0; element < elements; ++element) {
const auto usage = r.at<std::uint8_t>(d + element * 4);
if (usage < position || usage > weights_high) continue;
const auto stream = r.at<std::uint8_t>(d + element * 4 + 3);
const std::size_t offset = r.at<std::uint8_t>(d + element * 4 + 2);
require(r.at<std::uint8_t>(d + element * 4 + 1) == usage_format[usage] && stream < streams &&
offset + usage_size[usage] <= strides[stream],
"a skinning attribute has another format");
attributes[usage] = {bases[stream], strides[stream], offset, true};
}
for (const auto usage : {position, bones_low, weights_low}) require(attributes[usage].present, "a section is not skinned");
require(attributes[bones_high].present == attributes[weights_high].present, "a section's second influences are half there");
const std::size_t influences = attributes[bones_high].present ? 8 : 4;
const auto address = [&](std::uint8_t usage, std::size_t vertex) {
const auto& a = attributes[usage];
return a.base + vertex * a.stride + a.offset;
};
const auto first = static_cast<std::uint32_t>(result.vertices.size());
for (std::size_t vertex = 0; vertex < count; ++vertex) {
SkinnedVertex out;
out.position = g.at<std::array<float, 3>>(address(position, vertex));
for (const float coordinate : out.position) require(std::isfinite(coordinate), "a vertex is not finite");
unsigned total{};
for (std::size_t influence = 0; influence < influences; ++influence) {
const auto high = influence >= 4;
const auto weight = g.at<std::uint8_t>(address(high ? weights_high : weights_low, vertex) + influence % 4);
if (!weight) continue; // an unused slot's bone need not be set
const auto slot = g.at<std::uint16_t>(address(high ? bones_high : bones_low, vertex) + influence % 4 * 2);
require(slot < bone_count, "a vertex names a bone outside its palette");
out.bones[influence] = r.at<std::uint16_t>(bones + slot * 2u);
out.weights[influence] = weight;
total += weight;
}
require(total == 255, "a vertex's weights do not add up");
result.vertices.push_back(out);
}
const std::size_t start = std::size_t{r.at<std::uint32_t>(s + first_index)} * 2;
require(start + std::size_t{triangles} * 6 <= layout.index_bytes, "a section's indices run past the index buffer");
for (std::size_t i = 0; i < std::size_t{triangles} * 3; ++i) {
const auto index = g.at<std::uint16_t>(layout.vertex_bytes + start + i * 2);
require(index < count, "a triangle names a vertex outside its section");
result.triangles.push_back(first + index);
}
}
return result;
}
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// Frostbite skinned MeshSet resources: one level of detail's triangles, and how each vertex is
// weighted to the skeleton's bones. Only what a skinned mesh needs; the layout was worked out
// on skate.'s costume meshes, first by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate).
#include "binary_io.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
namespace dingosdk::frostbite {
struct MeshLod {
Guid chunk; // the geometry chunk: the vertex buffer, then the index buffer
std::uint32_t vertex_bytes{}, index_bytes{};
};
// The `lod`-th level of detail (0 the finest) of a MeshSet resource. Throws for a layout this
// reader does not know.
[[nodiscard]] MeshLod read_mesh_lod(std::span<const std::byte> resource, std::size_t lod);
// What a geometry comparison needs, of any MeshSet of the known layout, skinned or not and
// whatever its index size: how many levels of detail it has, a level's geometry chunk, and
// where that level's vertices are. Nothing of the skinning is read, so a mesh the skinned
// reader refuses for its bones still gives its positions. Throws for another layout, and for
// positions that are not three floats.
[[nodiscard]] std::size_t mesh_lod_count(std::span<const std::byte> resource);
[[nodiscard]] MeshLod mesh_lod_geometry(std::span<const std::byte> resource, std::size_t lod);
[[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);
struct SkinnedVertex {
std::array<float, 3> position{}; // model space
std::array<std::uint16_t, 8> bones{}; // skeleton bones
std::array<std::uint8_t, 8> weights{}; // summing to 255; 0 for an unused influence
};
struct SkinnedMesh {
std::vector<SkinnedVertex> vertices;
std::vector<std::uint32_t> triangles; // vertex indices, three a triangle
};
// That level's sections as one mesh, from its geometry chunk. Throws when they do not fit.
[[nodiscard]] SkinnedMesh read_skinned_mesh(std::span<const std::byte> resource, std::size_t lod,
std::span<const std::byte> geometry);
}
+36
View File
@@ -0,0 +1,36 @@
#include "skeleton_asset.h"
#include <stdexcept>
#include <string_view>
namespace dingosdk::frostbite {
namespace {
void require(bool valid, const char* message) {
if (!valid) throw std::runtime_error(std::string("SkeletonAsset: ") + message);
}
template <class T> const T& as(const ebx::Value& value, const char* what) {
const auto* result = std::get_if<T>(&value.data);
require(result != nullptr, what);
return *result;
}
const ebx::Value& field(const ebx::Object& object, std::string_view name) {
const auto* found = object.find(name);
require(found != nullptr, "a field is missing");
return found->value;
}
}
Skeleton read_skeleton(const ebx::Object& asset) {
const auto& names = as<ebx::Value::Array>(field(asset, "BoneNames"), "BoneNames is not a list");
const auto& hierarchy = as<ebx::Value::Array>(field(asset, "Hierarchy"), "Hierarchy is not a list");
const auto count = names.size();
require(count > 0 && hierarchy.size() == count, "its lists disagree on the bone count");
Skeleton result;
for (std::size_t bone = 0; bone < count; ++bone) {
result.names.push_back(as<std::string>(names[bone], "a bone name is not text"));
const auto parent = as<std::int64_t>(hierarchy[bone], "a parent is not a number");
require(parent >= -1 && parent < static_cast<std::int64_t>(bone), "a bone's parent does not come before it");
result.parents.push_back(static_cast<int>(parent));
}
return result;
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Frostbite SkeletonAssets: a skeleton's bones and how they hang together.
#include "ebx_document.h"
#include <string>
#include <vector>
namespace dingosdk::frostbite {
struct Skeleton {
std::vector<std::string> names;
std::vector<int> parents; // each bone's, before it; -1 for the root
};
// `asset` is the SkeletonAsset (an EBX document's root object). Throws when it is not one.
[[nodiscard]] Skeleton read_skeleton(const ebx::Object& asset);
}
+11 -1
View File
@@ -133,6 +133,10 @@ std::string c_string_at(const std::span<const std::byte> data, const std::size_t
throw std::runtime_error("TOC name is unterminated");
}
void expect_table(const BinaryReader& reader, std::size_t count, std::size_t entrySize, const char* what) { // before sizing by a file's count
if (count > reader.remaining() / entrySize) throw std::out_of_range(what);
}
class HuffmanNames final {
public:
HuffmanNames(const std::span<const std::byte> data,
@@ -142,6 +146,7 @@ public:
const std::size_t tableCount) {
BinaryReader reader(data);
reader.seek(namesOffset);
expect_table(reader, namesCount, 4, "TOC name count exceeds the file");
words_.reserve(namesCount);
for (std::size_t index = 0; index < namesCount; ++index) words_.push_back(reader.u32(Endian::big));
reader.seek(tableOffset);
@@ -364,9 +369,11 @@ TocDocument read_toc(const std::span<const std::byte> data,
TocDocument result;
result.flags = flags;
reader.seek(bundleHashOffset);
expect_table(reader, bundlesSize, 4, "TOC bundle count exceeds the file");
result.bundleHashMap.reserve(bundlesSize);
for (std::size_t index = 0; index < bundlesSize; ++index) result.bundleHashMap.push_back(reader.i32(Endian::big));
reader.seek(bundleDataOffset);
expect_table(reader, bundlesSize, 16, "TOC bundle count exceeds the file");
result.bundles.reserve(bundlesSize);
for (std::size_t index = 0; index < bundlesSize; ++index) {
const auto nameOffset = reader.u32(Endian::big);
@@ -389,12 +396,15 @@ TocDocument read_toc(const std::span<const std::byte> data,
}
if (chunksSize) {
reader.seek(chunkHashOffset);
expect_table(reader, chunksSize, 4, "TOC chunk count exceeds the file");
result.chunkHashMap.reserve(chunksSize);
for (std::size_t index = 0; index < chunksSize; ++index) result.chunkHashMap.push_back(reader.i32(Endian::big));
std::vector<std::uint32_t> words(static_cast<std::size_t>(dataCount));
reader.seek(chunkDataOffset);
expect_table(reader, static_cast<std::size_t>(dataCount), 4, "TOC chunk data count exceeds the file");
std::vector<std::uint32_t> words(static_cast<std::size_t>(dataCount));
for (auto& value : words) value = reader.u32(Endian::big);
reader.seek(chunkGuidOffset);
expect_table(reader, chunksSize, 20, "TOC chunk count exceeds the file");
result.chunks.reserve(chunksSize);
for (std::size_t index = 0; index < chunksSize; ++index) {
const auto guidBytes = reader.view(16);
+86
View File
@@ -0,0 +1,86 @@
// Game textures read from an installed game: dingosdk_game_textures_tests <Skate folder>.
// Without a folder there is nothing to read, and it passes.
#include "Engine/Vfs/game_textures.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/ui_textures.h"
#include <algorithm>
#include <iostream>
#include <stdexcept>
using namespace dingosdk;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
// How much of a region of the image is solid, 0 to 1.
float solid(const frostbite::Image& image, const addr::ui_textures::Region& region) {
const auto left = static_cast<std::uint32_t>(region.left * image.width), right = static_cast<std::uint32_t>(region.right * image.width);
const auto top = static_cast<std::uint32_t>(region.top * image.height), bottom = static_cast<std::uint32_t>(region.bottom * image.height);
std::size_t opaque{}, all{};
for (auto y = top; y < bottom; ++y)
for (auto x = left; x < right; ++x, ++all) opaque += image.rgba[(std::size_t{y} * image.width + x) * 4 + 3] >= 128;
return all ? static_cast<float>(opaque) / static_cast<float>(all) : 0.0f;
}
void the_overlays_textures_read(const char* game_root) {
namespace ui = addr::ui_textures;
vfs::GameTextures textures(game_root);
for (const auto& texture : {ui::airtime, ui::wipeout, ui::spread_eagle, ui::gap_height, ui::stopwatch}) {
const auto image = textures.read(texture.toc, texture.bundle, texture.name, 64);
check(image.width == 64 && image.height == 64 && image.rgba.size() == 64 * 64 * 4, "a 64 pixel icon");
}
const auto broken = textures.read(ui::wipeout_broken.toc, ui::wipeout_broken.bundle, ui::wipeout_broken.name, 64);
check(broken.width == 64 && broken.height == 60, "a wider icon fits the square, its aspect kept");
const auto wheel = textures.read(ui::flaming_wheel.toc, ui::flaming_wheel.bundle, ui::flaming_wheel.name, 64);
check(wheel.width == 64 && wheel.height == 64, "a larger icon is taken from a smaller mip");
// Streamed: the full size comes from the TOC's own chunk, not the bundle's small mips.
const auto logo = textures.read(ui::thrasher_wordmark.toc, ui::thrasher_wordmark.bundle, ui::thrasher_wordmark.name, 512);
check(logo.width == 512 && logo.height == 256, "the streamed logo at full size");
bool opaque{}, clear{};
for (std::size_t i = 3; i < logo.rgba.size(); i += 4) (logo.rgba[i] ? opaque : clear) = true;
check(opaque && clear, "the logo on a clear background");
// Beneath the wordmark's arch, between a fifth in from either side, as fractions of the whole logo.
const auto& mark = ui::thrasher_wordmark.region;
const float fifth = (mark.right - mark.left) / 5;
check(solid(logo, {mark.left + fifth, mark.top + ui::thrasher_wordmark_arch * (mark.bottom - mark.top), mark.right - fifth, mark.bottom}) == 0,
"the wordmark's arch is clear beneath");
bool refused{};
try { (void)textures.read(ui::airtime.toc, ui::airtime.bundle, "ui/textures/icons/no_such_icon", 64); }
catch (const std::runtime_error&) { refused = true; }
check(refused, "a missing texture is refused");
}
void the_ui_shapes_read(const char* game_root) {
namespace ui = addr::ui_textures;
vfs::GameTextures textures(game_root);
const auto read = [&](const ui::Texture& texture, std::uint32_t side) {
return textures.read(texture.toc, texture.bundle, texture.name, side);
};
const auto bar = read(ui::brush_bar, 512), streak = read(ui::streak, 512);
check(bar.width == 512 && bar.height == 192 && streak.width == 512 && streak.height == 76, "the strokes, their aspect kept");
check(solid(bar, ui::brush_bar.body) > 0.95f && solid(bar, {}) < 0.6f, "a stroke's body is its solid bar, not its splatter");
check(solid(streak, ui::streak.body) > 0.15f && solid(streak, {}) < solid(streak, ui::streak.body),
"the streak tapers within its body");
const auto tile = read(ui::rough_tile, 256);
check(tile.width == 256 && solid(tile, {ui::rough_tile.slice, ui::rough_tile.slice, 1 - ui::rough_tile.slice,
1 - ui::rough_tile.slice}) > 0.99f,
"the tile is solid inside its rough edges");
const auto scratches = read(ui::scratches, 512);
std::uint8_t brightest{};
for (std::size_t i = 0; i < scratches.rgba.size(); i += 4) brightest = std::max(brightest, scratches.rgba[i]);
check(scratches.width == 512 && solid(scratches, {}) > 0.99f && brightest > 64, "light scratches on opaque black");
}
}
int main(int argc, char** argv) {
try {
if (argc > 1 && *argv[1]) {
the_overlays_textures_read(argv[1]);
the_ui_shapes_read(argv[1]);
}
} catch (const std::exception& error) {
std::cerr << "FAILED: " << error.what() << '\n';
return 1;
}
std::cout << "Game texture tests passed.\n";
return 0;
}
+69 -3
View File
@@ -73,10 +73,70 @@ void rules() {
check(copies({"asset 3 mod_ap"}, {"asset 1 coat_dmpreset"}).empty(), "a mod's own look on a store item's shape is the mod's");
check(copies({}, {"asset 1 coat_dmpreset"}).empty(), "the shape of a store item that has a look is not that item");
check(copies({}, {"asset 1 costume_dmpreset"}) == "Own_Store_Costume", "a store costume that is only a shape is told by it");
check(copies({"asset 3 mod_ap"}, {"asset 1 costume_dmpreset"}).empty(), "unless the mod gives it a look of its own");
check(copies({"asset 3 mod_ap"}, {"asset 1 costume_dmpreset"}) == "Own_Store_Costume", "and a look of the mod's own over it is still that costume");
check(copies({}, {"asset 1 hoodie_dmpreset"}).empty(), "a shape free items use is free");
{
// A whole costume the store sells with looks of its own (colourways): its shape is still it.
auto suit = made_of("Own_Store_Suit", {"asset 20 store_suit_ap"}, {"asset 1 suit_dmpreset"}, "s");
suit.costume = true;
game.add(suit, true);
check(copies({"asset 20 mod_ap"}, {"asset 1 suit_dmpreset"}) == "Own_Store_Suit", "a mod's look on a store costume's mesh is that costume");
check(copies({"asset 20 mod_ap", "asset 21 mod_two_ap"}, {"asset 1 suit_dmpreset"}) == "Own_Store_Suit", "however many looks it brings");
auto free_suit = made_of("Own_Free_Suit", {"asset 20 free_suit_ap"}, {"asset 1 free_suit_dmpreset"}, "t");
free_suit.costume = true;
game.add(free_suit, false);
check(copies({"asset 20 mod_ap"}, {"asset 1 free_suit_dmpreset"}).empty(), "on a free costume's it is the mod's");
}
check(copies({}, {}, "e") == "Own_Store_Voice", "a store item's data, field for field, is that store item");
check(copies({}, {}, "f").empty(), "data a free item has as well is free");
// Costumes by their geometry. A mesh is rows of points here: `count` of them from `first`.
const auto points = [](float first, std::size_t count) {
std::vector<std::array<float, 3>> positions;
for (std::size_t i = 0; i < count; ++i) positions.push_back({first + static_cast<float>(i) * 0.01f, 1.0f, -0.25f});
return mesh_points(positions);
};
// A mesh of the mod's own making: nowhere near any of the game's.
const auto own = [](std::size_t count) {
std::vector<std::array<float, 3>> positions;
for (std::size_t i = 0; i < count; ++i) positions.push_back({static_cast<float>(i) * 0.01f, 7.0f, 3.5f});
return mesh_points(positions);
};
const auto joined = [](std::vector<std::uint64_t> a, const std::vector<std::uint64_t>& b) {
a.insert(a.end(), b.begin(), b.end());
std::sort(a.begin(), a.end());
a.erase(std::unique(a.begin(), a.end()), a.end());
return a;
};
check(mesh_point({1.0f, 2.0f, 3.0f}) == mesh_point({1.0001f, 2.0001f, 2.9999f}) &&
mesh_point({1.0f, 2.0f, 3.0f}) != mesh_point({1.001f, 2.0f, 3.0f}), "a vertex is told to half a millimetre");
check(mesh_points({{1, 2, 3}, {1, 2, 3}, {0, 0, 0}}).size() == 2, "a place is counted once");
StoreMeshes meshes;
// Three store costumes and a free one; the first two share a body (2000 points) under them.
const auto body = points(0, 2000), astronaut = points(100, 10000), reaper = points(300, 8000);
meshes.add("Own_Store_Astronaut", true, joined(astronaut, body));
meshes.add("Own_Store_Reaper", true, joined(reaper, body));
meshes.add("Own_Store_Astronaut", true, points(100, 4000)); // a coarser level of detail: the same costume
meshes.add("Own_Free_Suit", false, points(-200, 9000));
meshes.add("Own_Store_Robe", true, joined(points(-400, 6000), points(-200, 3000))); // half of it a free costume's
check(meshes.original(joined(astronaut, body)) == "Own_Store_Astronaut", "a store costume's mesh is that costume");
check(meshes.original(astronaut) == "Own_Store_Astronaut", "under any name, and without the body");
check(meshes.original(points(100, 1200)) == "Own_Store_Astronaut", "a part of it is that costume too");
check(meshes.original(joined(points(100, 1200), own(30000))) == "Own_Store_Astronaut",
"and stays it inside a much larger mesh of the mod's own");
check(meshes.original(joined(points(100, 700), own(1000))) == "Own_Store_Astronaut",
"a small part is, when it is much of the mod's mesh");
check(meshes.original(points(100, 1200)) == "Own_Store_Astronaut" && meshes.original(joined(points(100, 1300), own(30000))) == "Own_Store_Astronaut",
"a tenth of the costume's finest level is enough, whatever its coarser levels add");
check(meshes.original(joined(points(100, 700), own(30000))).empty(), "a small part of a costume in a large mesh is not");
check(meshes.original(points(100, 200)).empty() && meshes.original(points(100, 299)).empty(), "nor are a few vertices");
check(meshes.original(body).empty(), "the body two costumes share is nobody's");
check(meshes.original(joined(body, own(5000))).empty(), "so a mod's own costume on it is the mod's");
check(meshes.original(points(-200, 9000)).empty() && meshes.original(points(-200, 3000)).empty(), "a free costume's mesh is free");
check(meshes.original(points(-400, 6000)) == "Own_Store_Robe", "what only the store costume has of it is still its own");
check(meshes.original(own(20000)).empty(), "a mesh of the mod's own is the mod's");
check(meshes.original(joined(points(100, 1500), points(300, 3000))) == "Own_Store_Reaper", "of two it takes from, the one it takes most of");
check(StoreMeshes{}.original(astronaut).empty(), "nothing is taken when no costume was read");
check(copies({}, {}, "").empty(), "data with nothing in it says nothing");
check(copies({}, {}).empty(), "an item of the mod's own is no copy");
}
@@ -327,10 +387,16 @@ void installed_mods(const fs::path& game, const fs::path& folder, const StoreIte
const auto found = check_store_copies(catalog, sold, &notes);
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
std::cout << folder.string() << ": " << catalog.mods.size() << " mod(s) checked in " << ms << " ms against "
<< found.game_items << " of the game's items, " << found.items.size() << " copy(ies) of store items in "
<< found.game_items << " of the game's items, " << found.mod_meshes << " mesh level(s) against "
<< found.game_costumes << " of the game's costumes, " << found.items.size() << " copy(ies) of store items in "
<< found.mods.size() << " of them\n";
for (const auto& source : found.mods)
std::cout << " " << source.mod << ": " << source.count << ", e.g. " << source.example << " is " << source.original << '\n';
std::cout << " " << source.mod << ": " << source.count << ", e.g. " << source.example << " is " << source.original
<< (source.by_mesh ? " (by its mesh)" : " (by what its item is fitted on)") << '\n';
for (const auto& mesh : found.meshes_seen)
std::cout << " mesh " << mesh.mod << ": " << mesh.mesh << ", " << mesh.points << " points, " << mesh.closest.shared
<< " of them only in " << (mesh.closest.key.empty() ? std::string("no store costume") : mesh.closest.key + " (" +
std::to_string(mesh.closest.costume_points) + " points)") << '\n';
for (const auto& note : notes) std::cout << " note: " << note << '\n';
}
} // namespace
@@ -0,0 +1,90 @@
// One Thunderstore package looked up by name (Engine/Vfs/thunderstore_package.h): the name a
// host gives, and what Thunderstore's answer has to say before the game downloads it.
#include "Engine/Vfs/thunderstore_package.h"
#include <iostream>
#include <string>
using namespace dingosdk;
namespace package = thunderstore_package;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
// Thunderstore's answer for a package, as /api/experimental/package/<owner>/<name>/ gives it.
std::string answer(std::string_view owner, std::string_view name, std::string_view version, std::string_view community,
std::string_view category, std::string_view review, bool deprecated = false) {
return std::string("{\"namespace\":\"") + std::string(owner) + "\",\"name\":\"" + std::string(name) +
"\",\"full_name\":\"x\",\"is_deprecated\":" + (deprecated ? "true" : "false") +
",\"latest\":{\"version_number\":\"" + std::string(version) +
"\",\"icon\":\"https://gcdn.thunderstore.io/live/repository/icons/x.png\",\"description\":\"A plaza\\nby the sea\",\"download_url\":\"https://elsewhere.example/steal.zip\",\"is_active\":true},\"community_listings\":[{\"has_nsfw_content\":false,"
"\"categories\":[\"" + std::string(category) + "\"],\"community\":\"" + std::string(community) +
"\",\"review_status\":\"" + std::string(review) + "\"}]}";
}
}
int main() {
{
const auto plain = package::parse_name("Sandos-Vancouver_Plaza");
check(plain && plain->owner == "Sandos" && plain->name == "Vancouver_Plaza" && plain->version.empty() &&
plain->folder() == "Sandos-Vancouver_Plaza", "a package name read");
const auto versioned = package::parse_name("Sandos-Vancouver_Plaza-2.0.0");
check(versioned && versioned->version == "2.0.0" && versioned->folder() == "Sandos-Vancouver_Plaza", "and with its version");
for (const char* bad : {"", "Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "Sandos-Plaza-two",
"../x-y", "Sandos-Plaza/..", "Sandos-Pla.za", "a-b-1.2.3-4"})
check(!package::parse_name(bad), bad);
const auto name = *versioned;
check(package::details_url(name) == L"https://thunderstore.io/api/experimental/package/Sandos/Vancouver_Plaza/", "where it is asked about");
check(package::download_url(name, "2.0.0") == L"https://thunderstore.io/package/download/Sandos/Vancouver_Plaza/2.0.0/",
"where it is downloaded from");
}
{
const package::Name newest{"Sandos", "Vancouver_Plaza", ""}, pinned{"Sandos", "Vancouver_Plaza", "1.4.0"};
const auto listed = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed");
auto choice = package::choose(listed, newest, "reskate");
check(choice.ok && choice.version == "2.0.0" && choice.newest == "2.0.0", "a listed map is taken, its newest version");
check(choice.description == "A plaza by the sea", "with what it says of itself, on one line");
check(choice.icon == "https://gcdn.thunderstore.io/live/repository/icons/x.png", "and its icon, from Thunderstore's own host");
{
auto elsewhere = listed;
const std::string host = "gcdn.thunderstore.io";
elsewhere.replace(elsewhere.find(host), host.size(), "thunderstore.io.example.com");
const auto other = package::choose(elsewhere, newest, "reskate");
check(other.ok && other.icon.empty(), "an icon anywhere else is not fetched");
}
choice = package::choose(listed, pinned, "reskate");
check(choice.ok && choice.version == "1.4.0" && choice.newest == "2.0.0", "or the version the host has, with the newest to fall back on");
check(package::choose(answer("sandos", "vancouver_plaza", "2.0.0", "ReSkate", "maps", "approved"), newest, "reskate").ok,
"names compare whatever their case");
const auto refused = [&](const std::string& text, const char* what) {
const auto result = package::choose(text, newest, "reskate");
check(!result.ok && !result.reason.empty() && result.version.empty(), what);
};
refused(answer("Someone", "Else", "2.0.0", "reskate", "Maps", "unreviewed"), "another package's answer is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"), "a package that is not a map is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "othergame", "Maps", "unreviewed"), "a package of another community is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "rejected"), "a package the moderators rejected is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed", true), "a deprecated package is refused");
refused(answer("Sandos", "Vancouver_Plaza", "latest", "reskate", "Maps", "unreviewed"), "a version that is not one is refused");
{
auto marked = listed;
const std::string off = "\"has_nsfw_content\":false";
marked.replace(marked.find(off), off.size(), "\"has_nsfw_content\":true");
refused(marked, "a package marked NSFW is refused");
}
refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing is refused");
refused("not json", "an answer that is not JSON is refused");
refused("[]", "an answer that is not an object is refused");
}
if (failures) {
std::cerr << failures << " failure(s)\n";
return 1;
}
std::cout << "thunderstore package tests passed\n";
return 0;
}
+65 -3
View File
@@ -1,10 +1,15 @@
#include "game_archives.h"
#include "Engine/Resource/cas_codec.h"
#include "Engine/Core/Platform/path_text.h"
#include <charconv>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <mutex>
#include <stdexcept>
#ifdef _WIN32
#include <Windows.h>
#endif
namespace dingosdk::vfs {
namespace fs = std::filesystem;
@@ -40,6 +45,29 @@ std::vector<std::uint32_t> GameArchives::chunks_in(const std::string& directory)
return chunks;
}
#ifdef _WIN32
struct GameArchives::Open {
std::mutex mutex;
std::map<std::wstring, HANDLE> files;
~Open() {
for (const auto& [path, file] : files) CloseHandle(file);
}
};
void GameArchives::forget(const fs::path& file) const {
if (!open_) return;
std::lock_guard lock(open_->mutex);
if (const auto found = open_->files.find(file.wstring()); found != open_->files.end()) {
CloseHandle(found->second);
open_->files.erase(found);
}
}
#else
struct GameArchives::Open {};
void GameArchives::forget(const fs::path&) const {}
#endif
void GameArchives::keep_open() { if (!open_) open_ = std::make_shared<Open>(); }
GameArchives::GameArchives(fs::path root, const native_db::Node& layout) : root_(std::move(root)) {
std::map<std::set<std::uint16_t>, std::vector<std::string>> byArchives;
std::error_code error;
@@ -108,6 +136,38 @@ std::vector<std::byte> GameArchives::read(const fs::path& root, const fb::CasIde
std::uint32_t offset, std::uint32_t size) const {
const auto path = root / "Win32" / fs::path(directory(location.installChunk)) /
fs::path(archive_file(location.archive));
#ifdef _WIN32
if (open_) {
HANDLE file{};
{
std::lock_guard lock(open_->mutex);
auto& held = open_->files[path.wstring()];
if (!held) {
// Shared in full: the game, a mod manager or this merge may hold, link or
// append to the same file meanwhile.
held = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (held == INVALID_HANDLE_VALUE) {
open_->files.erase(path.wstring());
throw std::runtime_error("Cannot open " + path_utf8(path));
}
}
file = held;
}
std::vector<std::byte> bytes(size);
// Read at the offset itself, not at a file position threads would share.
for (std::uint32_t done = 0; done < size;) {
OVERLAPPED at{};
at.Offset = offset + done; // an archive's payloads are addressed in 32 bits
if (at.Offset < offset) throw std::runtime_error("Cannot read " + path_utf8(path));
DWORD read{};
if (!ReadFile(file, bytes.data() + done, size - done, &read, &at) || !read)
throw std::runtime_error("Cannot read " + path_utf8(path));
done += read;
}
return bytes;
}
#endif
std::ifstream input(path, std::ios::binary);
if (!input) throw std::runtime_error("Cannot open " + path_utf8(path));
input.seekg(static_cast<std::streamoff>(offset));
@@ -151,8 +211,10 @@ const std::string& GameArchives::directory(std::uint32_t installChunk) const {
std::optional<std::uint16_t> GameArchives::archive_index(const std::string& stem) {
const auto digits = stem.find_last_not_of("0123456789");
if (digits == std::string::npos || digits + 1 >= stem.size()) return {};
const auto value = std::stoul(stem.substr(digits + 1));
if (value > 0xFFFF) return {};
return static_cast<std::uint16_t>(value);
std::uint16_t value{};
const auto end = stem.data() + stem.size();
const auto [last, error] = std::from_chars(stem.data() + digits + 1, end, value); // range error past 65535, never throws
if (error != std::errc{} || last != end) return {};
return value;
}
}
+10
View File
@@ -6,6 +6,7 @@
#include <cstdint>
#include <filesystem>
#include <map>
#include <memory>
#include <optional>
#include <set>
#include <span>
@@ -63,8 +64,17 @@ public:
[[nodiscard]] const std::filesystem::path& root() const noexcept { return root_; }
static std::optional<std::uint16_t> archive_index(const std::string& stem);
// From here on every archive read stays open until this object goes: for a merge, which
// reads tens of thousands of payloads out of a few hundred files. Opening a file costs
// far more than reading from it (the PC's anti-virus checks every open and close).
// Reads may then come from any thread.
void keep_open();
// Closes an archive kept open, before the file is replaced by another.
void forget(const std::filesystem::path& file) const;
private:
struct Open; // the archives kept open (keep_open); null: each read opens its file
std::shared_ptr<Open> open_;
std::filesystem::path root_;
std::map<std::string, std::set<std::uint16_t>> directories_;
std::map<std::uint32_t, std::string> chunkDirectory_;
+57
View File
@@ -0,0 +1,57 @@
#include "game_textures.h"
#include <algorithm>
#include <stdexcept>
namespace dingosdk::vfs {
namespace fb = frostbite;
GameTextures::GameTextures(std::filesystem::path gameRoot) : data_(std::move(gameRoot)) {}
const fb::TocDocument& GameTextures::toc(std::string_view name) {
auto found = tocs_.find(name);
if (found == tocs_.end()) found = tocs_.emplace(std::string(name), data_.read_toc(name)).first;
return found->second;
}
const GameBundle& GameTextures::bundle(std::string_view toc_name, std::string_view name) {
const auto key = std::make_pair(std::string(toc_name), std::string(name));
auto found = bundles_.find(key);
if (found == bundles_.end()) {
auto read = data_.read_bundle(toc(toc_name), name);
if (!read) throw std::runtime_error("the bundle " + std::string(name) + " is missing");
found = bundles_.emplace(key, std::move(*read)).first;
}
return found->second;
}
fb::Image GameTextures::read(std::string_view toc_name, std::string_view bundle_name, std::string_view name,
std::uint32_t side) {
if (!side) throw std::runtime_error("a texture needs a size to fit");
const auto& in = bundle(toc_name, bundle_name);
std::size_t index{};
if (!in.find(fb::AssetKind::resource, name, &index) || !in.payload(fb::AssetKind::resource, index))
throw std::runtime_error("the texture " + std::string(name) + " is missing");
const auto& resource = in.manifest.resources[index];
if (resource.resourceType != fb::texture_resource_type)
throw std::runtime_error(std::string(name) + " is not a texture");
const auto header = fb::read_texture_header(data_.read(*in.payload(fb::AssetKind::resource, index)), resource.resourceMeta);
// A streamed texture's bundle holds only its small mips; the TOC's own chunk holds them all.
std::vector<std::byte> pixels;
const auto& chunks = toc(toc_name).chunks;
const auto whole = std::find_if(chunks.begin(), chunks.end(),
[&](const fb::TocChunk& chunk) { return chunk.guid == header.chunk && !chunk.removed; });
std::size_t chunk{};
if (whole != chunks.end()) pixels = data_.read({whole->location, whole->offset, whole->size});
else if (in.find_chunk(header.chunk, &chunk) && in.payload(fb::AssetKind::chunk, chunk))
pixels = data_.read(*in.payload(fb::AssetKind::chunk, chunk));
else throw std::runtime_error("the pixels of " + std::string(name) + " are missing");
// The smallest mip whose longer side still covers `side`.
const auto longer = std::max(header.width, header.height), shorter = std::min(header.width, header.height);
const auto minimum = longer <= side ? shorter : std::max<std::uint32_t>(1, (side * shorter + longer - 1) / longer);
auto image = fb::decode_texture(header, pixels, minimum);
const auto fitted = std::max(image.width, image.height);
if (fitted <= side) return image;
return fb::resize(image, std::max<std::uint32_t>(1, image.width * side / fitted),
std::max<std::uint32_t>(1, image.height * side / fitted));
}
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// Textures out of the installed game's data, decoded for display: icons, logos, anything the
// game ships as a 2D texture. Each superbundle TOC and bundle is read once per reader.
#include "game_bundles.h"
#include "Engine/Resource/texture.h"
#include <cstdint>
#include <filesystem>
#include <map>
#include <string>
#include <string_view>
#include <utility>
namespace dingosdk::vfs {
class GameTextures {
public:
// `gameRoot` is the folder with Skate.exe.
explicit GameTextures(std::filesystem::path gameRoot);
// The texture `name` in `bundle` of the superbundle `toc`, fitted into a `side` x `side`
// square with its aspect kept: decoded from its smallest mip that still covers it, then
// scaled down. Throws when it is missing or cannot be decoded.
[[nodiscard]] frostbite::Image read(std::string_view toc, std::string_view bundle, std::string_view name,
std::uint32_t side);
private:
const frostbite::TocDocument& toc(std::string_view name);
const GameBundle& bundle(std::string_view toc, std::string_view name);
GameData data_;
std::map<std::string, frostbite::TocDocument, std::less<>> tocs_;
std::map<std::pair<std::string, std::string>, GameBundle> bundles_;
};
}
+4 -1
View File
@@ -20,7 +20,7 @@ struct Internet {
}
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) {
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; };
if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos)
@@ -57,18 +57,21 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
DWORD length{}; size = sizeof(length);
if (WinHttpQueryHeaders(request.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 (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 (!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);
total += read;
if (watch) watch->received.store(total);
}
if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError());
+8 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <optional>
@@ -10,11 +11,17 @@ struct Download {
bool ok{};
unsigned long http_status{}, error{};
};
// A long download as another thread sees it: how far it is (`total` 0 when the server does
// not say), and a way to stop it (the download then fails with ERROR_CANCELLED).
struct Watch {
std::atomic<std::uint64_t> received{}, total{};
std::atomic<bool> cancel{};
};
// Streams an HTTPS GET into `destination`, which must not exist yet. Follows
// redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers
// that take longer than `timeout_seconds` in total.
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);
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch = nullptr);
// 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,
+182 -4
View File
@@ -12,6 +12,8 @@
#include <cstring>
#include <fstream>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <span>
#include <stdexcept>
@@ -81,8 +83,146 @@ std::string sdk_identity() {
return error ? std::string{} : std::to_string(size) + "-" + std::to_string(written);
}
// A mod brings a deck model of its own when its bundle lists name the deck's bundle. Deck graphics
// and grip tapes are items of their own and do not touch it.
bool changes_the_deck(const fs::path& mod) {
constexpr std::string_view deck_bundle = "deck_gen_popsicle_cas_main_bundlereftable";
std::error_code error;
std::vector<fs::path> lists{mod / L"layout.toc"};
for (fs::directory_iterator at(mod / L"Win32", error), end; !error && at != end; at.increment(error))
if (at->is_regular_file(error) && at->path().extension() == L".toc") lists.push_back(at->path());
for (const auto& list : lists) {
std::ifstream file(list, std::ios::binary);
if (!file) continue;
const std::string bytes{std::istreambuf_iterator<char>(file), {}};
if (bytes.find(deck_bundle) != std::string::npos) return true;
}
return false;
}
// The player's choice of deck is kept beside ReSkate's other files for the user, so that it is
// there before the profile is: the mods are put together as the game starts.
fs::path board_break_deck_file() {
std::array<wchar_t, 1024> text{};
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast<DWORD>(text.size()));
if (!size || size >= text.size()) return {};
return fs::path(std::wstring(text.data(), size)) / L"ReSkate" / L"BoardBreak.deck";
}
// A deck's name is a folder name's end: letters, digits and underscores.
bool deck_name(std::string_view name) {
return !name.empty() && name.size() <= maximum_mod_name &&
std::all_of(name.begin(), name.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; });
}
// Where a deck comes in the menus: the decks the feature was made with in their own order, then
// the others by name.
bool deck_before(const std::string& a, const std::string& b) {
const auto place = [](const std::string& name) {
return static_cast<std::size_t>(std::find(board_break_decks.begin(), board_break_decks.end(), name) - board_break_decks.begin());
};
return place(a) != place(b) ? place(a) < place(b) : a < b;
}
// The choice made in this run, and what the last live apply gave the game (none since launch: the
// launch's catalogue says).
struct BoardBreakApplied {
std::string deck;
std::vector<std::string> set_aside;
};
std::mutex board_break_mutex;
std::string chosen_deck;
std::optional<BoardBreakApplied> applied_board_break;
} // namespace
std::string board_break_deck_of(std::string_view mod) {
// Team-Board_Break_Wide and Board_Break_Wide are both the deck "wide".
constexpr std::string_view prefix = "board_break_";
auto name = lower(mod);
if (const auto team = name.rfind('-'); team != std::string::npos) name.erase(0, team + 1);
if (!name.starts_with(prefix) || !deck_name(std::string_view(name).substr(prefix.size()))) return {};
return name.substr(prefix.size());
}
std::string wanted_board_break_deck() noexcept {
try {
{
std::lock_guard lock(board_break_mutex);
if (!chosen_deck.empty()) return chosen_deck;
}
std::string choice;
if (const auto path = board_break_deck_file(); !path.empty()) {
std::ifstream file(path);
std::getline(file, choice);
}
while (!choice.empty() && (choice.back() == '\r' || choice.back() == ' ')) choice.pop_back();
choice = lower(choice);
return deck_name(choice) ? choice : std::string(board_break_decks.front());
} catch (...) {
return {};
}
}
bool save_board_break_deck(std::string_view deck) noexcept {
try {
if (!deck_name(deck)) return false;
const auto path = board_break_deck_file();
if (path.empty()) return false;
std::error_code error;
fs::create_directories(path.parent_path(), error);
std::ofstream file(path, std::ios::trunc);
file << deck << '\n';
if (!file) return false;
std::lock_guard lock(board_break_mutex);
chosen_deck = deck;
return true;
} catch (...) {
return false;
}
}
std::vector<std::filesystem::path> board_break_folders() noexcept {
std::vector<fs::path> found;
try {
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))
if (auto deck = board_break_deck_of(at->path().filename().string()); !deck.empty()) decks.emplace_back(std::move(deck), at->path());
std::sort(decks.begin(), decks.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); });
for (auto& deck : decks) found.push_back(std::move(deck.second));
} catch (...) {}
return found;
}
std::vector<std::string> installed_board_break_decks() noexcept {
std::vector<std::string> found;
try {
for (const auto& folder : board_break_folders()) {
auto deck = board_break_deck_of(folder.filename().string());
if (found.empty() || found.back() != deck) found.push_back(std::move(deck));
}
} catch (...) {}
return found;
}
std::string board_break_in_play() noexcept {
try {
{
std::lock_guard lock(board_break_mutex);
if (applied_board_break) return applied_board_break->deck;
}
return catalog().board_break_deck;
} catch (...) {
return {};
}
}
std::vector<std::string> set_aside_for_board_break() {
{
std::lock_guard lock(board_break_mutex);
if (applied_board_break) return applied_board_break->set_aside;
}
return catalog().set_aside;
}
void board_break_applied(const Catalog& catalog) noexcept {
try {
std::lock_guard lock(board_break_mutex);
applied_board_break = BoardBreakApplied{catalog.board_break_deck, catalog.set_aside};
} catch (...) {}
}
std::filesystem::path engine_data_root() noexcept {
try {
std::array<wchar_t, 32768> module_path{};
@@ -135,10 +275,42 @@ 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);
// Board Break (Extension/BoardBreak/board_break.h). The deck that can break is a mod's own
// copy of the game's one deck model, and any mod that reshapes the deck replaces that same
// model: the one higher in the load order has it. So the deck the player chose goes first
// for this run, whatever mods.json says of the order, and what would fight it is set aside:
// Board Break's other decks, and other mods that bring a deck model. Those are left out
// whole: with the breakable deck in place of its own, such a mod would have its trucks and
// wheels on a deck they were not made for. mods.json is not changed, and their archives are
// placed like a disabled mod's, so another deck can be chosen while the game runs. A choice
// that is not installed falls back to the first deck, then to any that is.
{
const auto enabled_deck = [&](const std::string& deck) {
return std::find_if(list.entries.begin(), list.entries.end(), [&](const auto& entry) {
const auto found = board_break_deck_of(entry.mod.name);
return entry.enabled && !found.empty() && (deck.empty() || found == deck);
});
};
auto first = list.entries.end();
for (const auto& deck : {wanted_board_break_deck(), std::string(board_break_decks.front()), std::string()})
if (first == list.entries.end()) first = enabled_deck(deck);
if (first != list.entries.end()) {
std::rotate(list.entries.begin(), first, first + 1);
result.notes.push_back("Board Break: " + list.entries.front().mod.name + " loads first, so its deck is the one in the game");
for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) {
if (!entry->enabled || (board_break_deck_of(entry->mod.name).empty() && !changes_the_deck(entry->mod.directory))) continue;
entry->enabled = false;
result.set_aside.push_back(entry->mod.name);
result.notes.push_back("Board Break: " + entry->mod.name + " brings a deck model too and is set aside for this run");
}
}
}
for (auto& entry : list.entries) {
if (!entry.enabled) {
result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name);
result.disabled.push_back(entry.mod.name);
if (std::find(result.set_aside.begin(), result.set_aside.end(), entry.mod.name) == result.set_aside.end()) {
result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name);
result.disabled.push_back(entry.mod.name);
}
if (entry.mod.provides_layout) result.inactive.push_back(std::move(entry.mod));
continue;
}
@@ -217,6 +389,12 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
build + ", the installed game is " + game_build);
result.inactive.erase(result.inactive.begin() + static_cast<std::ptrdiff_t>(i));
}
// Board Break's deck is the one in the game when its mod is among those that load and no mod
// above it brings a deck model of its own.
for (const auto& mod : result.mods) {
if (!(result.board_break_deck = board_break_deck_of(mod.name)).empty()) break;
if (changes_the_deck(mod.directory)) break;
}
// Any Mods folder gets a patch, even an empty one or one of disabled
// mods only: a mod enabled or dropped in while the game runs needs it
// mounted from launch.
@@ -225,9 +403,9 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
// Merge, and whenever a mod could not be merged cleanly, merge again
// without it. Removing one mod cannot break another, but a few rounds
// are allowed in case two damaged mods hid each other's problems.
for (int round = 0; round < 4; ++round) {
for (int round = 0;; ++round) {
merge = merge_mods(result, observe);
if (!merge.issue.empty()) break;
if (!merge.issue.empty() || round == 4) break; // a fifth merge only rebuilds without round four's removals
bool removed = false;
for (std::size_t i = result.mods.size(); i-- > 0;) {
const auto found = merge.problems.find(result.mods[i].name);
+33
View File
@@ -3,9 +3,11 @@
#include "mod_list.h"
#include "mod_merge.h"
#include <array>
#include <cstddef>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
namespace dingosdk::mods {
@@ -38,8 +40,39 @@ struct Catalog {
// Mods left out because they were built for another game version (Mod::outdated), enabled or
// not: they need updating. Also in `excluded` when they were enabled.
std::vector<Mod> outdated;
// Board Break: the name of its deck that is the one in the game ("standard", "wide" and so on),
// empty when none is and a board cannot break; and the enabled mods set aside for it, its
// other decks and other mods that bring a deck model. Those are not loaded and nothing about
// them waits to be applied.
std::string board_break_deck;
std::vector<std::string> set_aside;
};
// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model for every
// board, so each deck that can break is a content mod of its own that replaces it: any mod whose
// folder is named Board_Break_<Name> (also behind a team name, as a mod manager installs it:
// Team-Board_Break_<Name>). The name, in small letters, is what the deck goes by; the deck the
// player chose is the one that loads. 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"};
// The name of the deck a mod folder holds, or nothing for any other mod.
std::string board_break_deck_of(std::string_view mod_folder);
// The deck the player chose ("standard" when nothing was chosen): read whenever the mods are put
// together, at launch and at every live apply. Saving a choice makes it this run's at once.
std::string wanted_board_break_deck() noexcept;
bool save_board_break_deck(std::string_view deck) noexcept;
// The decks whose mod's folder is in Mods/, whatever mods.json says of them, in the menus' order,
// and those folders in the same order.
std::vector<std::string> installed_board_break_decks() noexcept;
std::vector<std::filesystem::path> board_break_folders() 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;
std::vector<std::string> set_aside_for_board_break();
void board_break_applied(const Catalog& catalog) noexcept;
// The engine's own data root: the -dataPath argument when present, otherwise
// the executable's directory. Mirrors the game so mod paths resolve the same.
std::filesystem::path engine_data_root() noexcept;
+201 -25
View File
@@ -6,14 +6,23 @@
#include "mod_store_copies.h"
#include "native_db.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <exception>
#include <iterator>
#include <limits>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <thread>
namespace dingosdk::mods {
using namespace detail;
@@ -38,12 +47,22 @@ constexpr std::array<std::string_view, 2> launch_superbundles{"Win32/globals.toc
// 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.
bool store_copy_problems(const Catalog& catalog, MergeReport& report) {
bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vector<std::string>& checked,
std::size_t threads, bool background) {
// (Only when some are skipped is the catalogue copied, without them.)
std::optional<Catalog> fewer;
if (!checked.empty()) {
fewer = all;
std::erase_if(fewer->mods, [&](const Mod& mod) {
return std::ranges::any_of(checked, [&](const std::string& name) { return lower(name) == lower(mod.name); });
});
}
const Catalog& catalog = fewer ? *fewer : all;
std::optional<content_cache::Catalogs> store;
const auto found = check_store_copies(catalog, [&store](const std::string& key) {
if (!store) store = content_cache::read_catalogs(content_cache::directory());
return store->reserved(key);
}, &report.notes);
}, &report.notes, threads, background);
for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem);
return !found.mods.empty();
}
@@ -52,6 +71,16 @@ bool store_copy_problems(const Catalog& catalog, MergeReport& report) {
MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, const MergeOptions& options) noexcept {
MergeReport report;
try {
// How long each step took, for the log: a merge that takes many minutes
// can then be traced to the step that takes them.
using Clock = std::chrono::steady_clock;
std::vector<std::pair<std::string, Clock::duration>> times;
auto lapStart = Clock::now();
const auto lap = [&](std::string step) {
const auto now = Clock::now();
times.emplace_back(std::move(step), now - lapStart);
lapStart = now;
};
const auto output = catalog.root / generated_folder;
std::error_code error;
@@ -68,7 +97,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// Disabled mods count too: their archives and map registration are placed at launch.
auto fingerprint = merge_fingerprint(catalog, mods, modFiles, storeKnown);
for (const auto& mod : catalog.inactive)
fingerprint += "\ninactive " + mod.name + " " + mod_fingerprint(mod.directory);
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)) {
@@ -78,15 +107,22 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// anything is built: the caller merges again without it, as it does for a
// mod that cannot be merged, and the patch on disk stays for that merge to
// reuse or replace.
if (storeKnown && store_copy_problems(catalog, report)) return report;
// Threads for the three steps that read many files: as many as there are cores at
// 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);
if (storeKnown && store_copy_problems(catalog, report, options.live ? options.checked : std::vector<std::string>{},
readers - 1, options.live)) return report;
if (!options.live) fs::remove_all(output, error);
lap("checking the mods");
// Progress: each mod's archives, each superbundle, then the layout.
// Progress: each mod's archives, the material grid, a live merge's load
// screens, the asset overrides, each superbundle, then the layout.
std::set<std::string> distinctTocs;
for (const auto& [mod, files] : modFiles)
for (const auto& toc : files.tocs) distinctTocs.insert(lower(toc));
for (const auto relative : launch_superbundles) distinctTocs.insert(lower(relative));
MergeProgress progress{0, mods.size() + distinctTocs.size() + 1, mods.size(), {}};
MergeProgress progress{0, mods.size() + distinctTocs.size() + (options.live ? 4 : 3), mods.size(), {}};
const auto advance = [&](std::string step) {
if (!observe) return;
progress.step = std::move(step);
@@ -217,11 +253,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
}
for (const auto& relative : files.archives) {
const auto path = fs::path(relative);
const auto stem = path.stem().string();
const auto digits = stem.find_last_not_of("0123456789");
if (digits == std::string::npos || digits + 1 >= stem.size())
throw std::runtime_error("Unexpected archive name: " + relative);
const auto number = static_cast<std::uint16_t>(std::stoul(stem.substr(digits + 1)));
const auto index = vfs::GameArchives::archive_index(path.stem().string());
if (!index) { // the mod's problem: the catalog merges again without it
report.problems[mod->name].push_back("Unexpected archive name: " + relative);
continue;
}
const auto number = *index;
// CasStore keys its directories relative to Win32, so the mod's
// own Win32/ prefix comes off before the two are matched up.
auto directory = lower(path.parent_path().generic_string());
@@ -284,6 +321,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
fs::create_directories(path.parent_path(), error);
write_file(path, {});
}
lap("linking");
// Maps that author their own surfaces all number them from the same
// first free slot of the game's material grid; they are combined into
@@ -309,51 +347,174 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
const bool keepRoot = options.live && rootNames == record.root && !rootNames.empty() &&
fs::exists(output / fs::path(root_level), error);
record.root = rootNames;
// Without steps of their own, the material grid and the asset overrides showed
// the last "Linking" for as long as they ran
// (https://github.com/Dingo-Shenanigans/ReSkate/issues/138).
advance("Planning the material grid");
// rootMods belongs to providers; finish using it before erasing its node.
const auto grid = plan_material_grid(rootMods, modFiles, store, baseRoot, gameRoot, report);
if (rootMods.empty()) {
providers.erase(std::string(root_level));
std::erase_if(superbundles, [](const std::string& relative) { return lower(relative) == root_level; });
}
if (options.live) report.load_screens = read_load_screens(mods, modFiles, store, baseRoot, gameRoot, report);
auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report);
lap("material grid");
if (options.live) {
advance("Reading load screens");
report.load_screens = read_load_screens(mods, modFiles, store, baseRoot, gameRoot, report);
lap("load screens");
}
advance("Collecting asset overrides");
auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report, readers - 1, options.live);
// Carried chunks point into their mod's archives; move them to where those landed.
for (auto& [name, chunks] : overrides.chunks)
for (const auto* mod : mods)
if (mod->name == name)
for (auto& chunk : chunks) store.shift(chunk.location, chunk.offset, &placements[mod]);
lap("asset overrides");
// The count above missed the root level when only a disabled map brings it, and
// that is merged too: from here each superbundle is a step, then the layout.
progress.total = progress.done + superbundles.size() + 1;
// Each superbundle is merged by itself: it reads the game's and the mods' files and
// what was worked out above, and what it writes waits in its own copy of the store
// (CasStore::waiting). So they are merged on several threads, and settled here one
// after another in the order they always were, which puts every byte where merging
// them in turn would have. A merge while the game runs uses fewer threads, at a lower
// priority: the game is using the others, and a player is waiting on this one.
struct Job {
std::string relative;
std::optional<CasStore> store; // waiting: what the merge wrote
MergeReport report; // its notes, problems and counts
ArchiveUse used;
fb::TocDocument merged;
// The bundles with a file in the waiting store, read back to be moved.
std::vector<std::pair<std::size_t, fb::BundleRegion>> held;
std::exception_ptr failure;
bool done{};
};
std::vector<Job> jobs;
jobs.reserve(superbundles.size());
for (const auto& relative : superbundles) {
if (keepRoot && lower(relative) == root_level) continue;
auto& job = jobs.emplace_back();
job.relative = relative;
job.store.emplace(store.waiting());
}
// Made here: the jobs only read the map.
for (const auto& [relative, shippedBy] : providers)
for (const auto* mod : shippedBy) placements[mod];
const auto run = [&](Job& job) noexcept {
try {
const auto& relative = job.relative;
std::vector<Source> sources;
for (const auto* mod : providers.at(lower(relative))) {
Source source;
source.root = mod->directory;
source.placement = &placements.at(mod);
try {
source.toc = fb::read_toc(read_file(mod->directory / fs::path(relative)));
} catch (const std::exception& failure) { // the mod's problem: the catalog merges again without it
job.report.problems[mod->name].push_back(relative + " could not be read: " + failure.what());
continue;
}
sources.push_back(std::move(source));
}
job.merged = combine(baseRoot / fs::path(relative), baseRoot, sources, job.report,
relative, job.used, *job.store, manifestArchive, gameRoot, grid, overrides);
if (job.store->holding())
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); }))
job.held.emplace_back(index, std::move(region));
}
} catch (...) {
job.failure = std::current_exception();
}
};
std::mutex jobsMutex;
std::condition_variable jobDone;
std::atomic<std::size_t> nextJob{0};
std::atomic<bool> stopJobs{false};
std::vector<std::jthread> workers;
// Declared after the workers, so it goes first: a merge that fails stops them
// taking more, and they are joined before anything they use goes.
struct Stop {
std::atomic<bool>& flag;
~Stop() { flag = true; }
} stopOnExit{stopJobs};
const std::size_t wantedWorkers = jobs.size() < 2 ? 0
: std::min<std::size_t>({jobs.size(), options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U});
try {
// Threads of their own, not the system's pool: the launcher holds the pool's
// threads back while the game starts (see world_layer_scan.cpp).
for (std::size_t index = 0; index < wantedWorkers; ++index)
workers.emplace_back([&] {
// (Under the game's own threads, so a merge shows as a wait and not as stutter.)
if (options.live) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
for (;;) {
const auto mine = nextJob.fetch_add(1);
if (mine >= jobs.size() || stopJobs) return;
run(jobs[mine]);
{
std::lock_guard lock(jobsMutex);
jobs[mine].done = true;
}
jobDone.notify_all();
}
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
std::size_t settledJobs{};
for (const auto& relative : superbundles) {
if (keepRoot && lower(relative) == root_level) {
advance("Keeping the root level as the game has it");
++report.superbundles;
continue;
}
auto& job = jobs[settledJobs++];
const auto& shippedBy = providers.at(lower(relative));
advance("Merging " + fs::path(relative).stem().string() +
(shippedBy.empty() ? std::string(" (the game's own)")
: shippedBy.size() > 1 ? " from " + std::to_string(shippedBy.size()) + " mods"
: " from " + shippedBy.front()->name));
std::vector<Source> sources;
for (const auto* mod : shippedBy) {
Source source;
source.root = mod->directory;
source.placement = &placements[mod];
source.toc = fb::read_toc(read_file(mod->directory / fs::path(relative)));
sources.push_back(std::move(source));
if (workers.empty()) {
run(job);
} else {
std::unique_lock lock(jobsMutex);
jobDone.wait(lock, [&] { return job.done; });
}
auto merged = combine(baseRoot / fs::path(relative), baseRoot, sources, report,
relative, used, store, manifestArchive, gameRoot, grid, overrides);
auto bytes = fb::write_patch_toc(merged.bundles, merged.chunks, merged.flags);
if (job.failure) std::rethrow_exception(job.failure);
// What it wrote goes into the patch's archives now, and what points at it follows.
store.settle(*job.store);
for (auto& [index, region] : job.held) {
for (auto& file : region.files) job.store->settled(file.location, file.offset);
job.merged.bundles[index].region = fb::write_bundle_region(region.files, region.inlineManifest);
}
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);
report.mergedBundles += job.report.mergedBundles;
report.mergedAssets += job.report.mergedAssets;
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) {
auto& list = report.problems[mod];
list.insert(list.end(), std::make_move_iterator(problems.begin()), std::make_move_iterator(problems.end()));
}
auto bytes = fb::write_patch_toc(job.merged.bundles, job.merged.chunks, job.merged.flags);
// Read the result back before publishing it: a TOC whose perfect
// hash does not resolve would take the game down at load time.
const auto check = fb::read_toc(bytes);
fb::verify_toc(check);
if (check.bundles.size() != merged.bundles.size() ||
check.chunks.size() != merged.chunks.size())
if (check.bundles.size() != job.merged.bundles.size() ||
check.chunks.size() != job.merged.chunks.size())
throw std::runtime_error("Merged TOC did not round-trip: " + relative);
write_file(output / fs::path(relative), bytes);
++report.superbundles;
// Done with: a big merge would otherwise hold every superbundle's until the end.
job.merged = {};
job.held = {};
job.store.reset();
}
// A live merge takes out the TOCs of superbundles no enabled mod ships
@@ -368,6 +529,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
record.tocs = superbundles;
write_placements(placementsPath, record);
}
lap("superbundles");
// The layout lists every superbundle the merged layer now provides.
advance("Writing the layout");
@@ -528,6 +690,20 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
failure.what());
}
}
lap("layout");
// After the stamp, which keeps the notes: a patch that is reused must not
// report the times of the merge that built it.
const auto seconds = [](Clock::duration elapsed) {
const auto tenths = (std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() + 50) / 100;
return std::to_string(tenths / 10) + "." + std::to_string(tenths % 10) + " s";
};
std::string summary;
Clock::duration all{};
for (const auto& [step, elapsed] : times) {
summary += (summary.empty() ? "Merge times: " : ", ") + step + " " + seconds(elapsed);
all += elapsed;
}
report.notes.push_back(summary + " (" + seconds(all) + " for this merge)");
report.built = true;
} catch (const std::exception& failure) {
report.issue = failure.what();
+5
View File
@@ -62,6 +62,11 @@ struct MergeOptions {
// only read at startup and stays as it is; the stamp goes, so the next
// launch builds a clean patch.
bool live{};
// Mods (folder names) already loaded in the running game, by the launch's merge or an
// earlier live one: they passed the store-copies check then, so a live merge holds only
// the others against it. Reading every mod's items and meshes again is most of what
// adding one map would otherwise wait on.
std::vector<std::string> checked;
};
// Combines every enabled mod into one patch under Mods/.reskate: superbundle
+62 -8
View File
@@ -24,15 +24,19 @@ bool unshift(const ArchivePlacement& placement, std::string_view directory, std:
}
CasStore::CasStore(fs::path baseRoot, fs::path output, const native_db::Node& layout)
: GameArchives(std::move(baseRoot), layout), output_(std::move(output)) {}
: GameArchives(std::move(baseRoot), layout), output_(std::move(output)) {
keep_open();
}
fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded) {
const auto path = output_ / L"Win32" / fs::path(directory(installChunk)) /
fs::path(archive_file(archive));
fs::path CasStore::archive_path(std::uint32_t installChunk, std::uint16_t archive) const {
return output_ / L"Win32" / fs::path(directory(installChunk)) / fs::path(archive_file(archive));
}
std::uint64_t CasStore::append(const fs::path& path, std::span<const std::byte> encoded) {
auto& offset = offsets_[path.wstring()];
if (!offset) {
fs::create_directories(path.parent_path());
forget(path); // unshare may put a private copy in its place
unshare(path);
std::error_code error;
const auto existing = fs::file_size(path, error);
@@ -47,10 +51,60 @@ fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t arc
if (!out || !out.write(reinterpret_cast<const char*>(encoded.data()),
static_cast<std::streamsize>(encoded.size())))
throw std::runtime_error("Cannot append to " + path_utf8(path));
fb::BundleFileInfo info{{true, installChunk, archive}, static_cast<std::uint32_t>(offset),
static_cast<std::uint32_t>(encoded.size())};
const auto start = offset;
offset += encoded.size();
return info;
return start;
}
fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded) {
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))
throw std::logic_error("A waiting store writes to one archive index");
archive_ = archive;
auto& held = held_[path.wstring()].bytes;
if (held.size() + encoded.size() > std::numeric_limits<std::uint32_t>::max())
throw std::runtime_error("The merged patch's own archive " + path_utf8(path) +
" is full (4 GB); restart Skate so it is built again");
const fb::BundleFileInfo info{{true, installChunk, waiting_archive}, static_cast<std::uint32_t>(held.size()),
static_cast<std::uint32_t>(encoded.size())};
held.insert(held.end(), encoded.begin(), encoded.end());
return info;
}
const auto start = append(path, encoded);
return {{true, installChunk, archive}, static_cast<std::uint32_t>(start), static_cast<std::uint32_t>(encoded.size())};
}
CasStore CasStore::waiting() const {
auto copy = *this;
copy.waiting_ = true;
copy.archive_.reset();
copy.held_.clear();
copy.offsets_.clear();
return copy;
}
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 = {};
}
}
bool CasStore::settled(fb::CasIdentifier& location, std::uint32_t& offset) const {
if (!waits(location)) return false;
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;
if (moved > std::numeric_limits<std::uint32_t>::max())
throw std::runtime_error("The merged patch's own archive " + path_utf8(fs::path(found->first)) +
" is full (4 GB); restart Skate so it is built again");
location.archive = *archive_;
offset = static_cast<std::uint32_t>(moved);
return true;
}
void CasStore::shift(fb::CasIdentifier& location, std::uint32_t& offset,
+21 -9
View File
@@ -11,6 +11,8 @@
#include <functional>
#include <map>
#include <set>
#include <unordered_map>
#include <unordered_set>
#include <stdexcept>
#include <tuple>
@@ -106,13 +108,15 @@ struct BundleState {
std::size_t gameMetadata{ChunkRecord::none};
std::vector<fb::Guid> shippedChunks;
// Keyed position of each asset, so replacing one stays constant time: these
// bundles hold tens of thousands of entries.
std::map<std::string, std::size_t, std::less<>> seen;
// bundles hold tens of thousands of entries. Hashed, here and below: the keys
// 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.
std::map<std::string, std::vector<Contribution>, std::less<>> history;
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.
std::map<std::string, fb::Sha1, std::less<>> baseSha;
std::unordered_map<std::string, fb::Sha1> baseSha;
// 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;
@@ -369,6 +373,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
// 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.
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;
for (const auto& [name, versions] : overrides.changed) changedNames.insert(name_hash(name));
for (const auto& [name, versions] : overrides.scripts) scriptNames.insert(name_hash(name));
const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement,
bool isBase, const fs::path& root) {
@@ -523,9 +531,11 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
if (propagate && (casBacked || !region.inlineManifest.empty())) {
std::string example;
auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest);
const auto replace = [&](std::vector<fb::BundleAsset>& assets, const auto& changes, std::size_t first) {
const auto replace = [&](std::vector<fb::BundleAsset>& assets, const auto& changes,
const std::unordered_set<std::uint64_t>& names, std::size_t first) {
for (std::size_t index = 0; index < assets.size(); ++index) {
auto& asset = assets[index];
if (!names.contains(name_hash(asset.name))) continue;
const auto named = changes.find(lower(asset.name));
if (named == changes.end()) continue;
const auto change = named->second.find(asset.sha1);
@@ -550,8 +560,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
}
}
};
replace(manifest.ebx, overrides.changed, 0);
replace(manifest.resources, overrides.scripts, manifest.ebx.size());
replace(manifest.ebx, overrides.changed, changedNames, 0);
replace(manifest.resources, overrides.scripts, scriptNames, manifest.ebx.size());
if (casBacked) casManifest = std::move(manifest);
else if (!overridden.empty()) region.inlineManifest = fb::write_binary_bundle(manifest);
if (!overridden.empty())
@@ -981,8 +991,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
for (const auto& [index, guids] : left) {
// Name the mod whose asset took the item's name, when that is why.
const Asset* taken{};
for (const auto& guid : guids)
if ((taken = held.replacement(guid))) break;
for (const auto& guid : guids) {
taken = held.replacement(guid);
if (taken) break;
}
report.notes.push_back(mod_of(index) + ": " + entry.asset.name +
": " + std::to_string(guids.size()) + (itemList ? " item(s)" : " entry(ies)") +
" left out of the list because the merged "
+1 -1
View File
@@ -40,7 +40,7 @@ void write_file(const fs::path& path, std::span<const std::byte> bytes) {
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output || !output.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size())))
static_cast<std::streamsize>(bytes.size())) || !output.flush()) // ~ofstream ignores a failed flush
throw std::runtime_error("Cannot write " + path_utf8(temporary));
}
if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
+48 -3
View File
@@ -26,6 +26,16 @@ namespace fb = frostbite;
std::string lower(std::string_view text);
std::vector<std::byte> read_file(const fs::path& path);
// A hash of `name` that ignores case (ASCII), to rule a name out before a lower-case copy of
// it is made to look it up: most names a merge looks at are in no table.
constexpr std::uint64_t name_hash(std::string_view name) noexcept {
std::uint64_t value = 0xCBF29CE484222325ULL;
for (char c : name) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
value = (value ^ static_cast<unsigned char>(c)) * 0x100000001B3ULL;
}
return value;
}
void write_file(const fs::path& path, std::span<const std::byte> bytes);
using vfs::archive_file;
void unshare(const fs::path& path);
@@ -105,16 +115,47 @@ public:
CasStore(fs::path baseRoot, fs::path output, const native_db::Node& layout);
// Appends an encoded payload to the merged patch's own archive for that
// install chunk, returning where it landed.
// install chunk, returning where it landed. A waiting store (below) keeps it
// instead and says where it is among what it holds.
[[nodiscard]] fb::BundleFileInfo write(std::uint32_t installChunk, std::uint16_t archive,
std::span<const std::byte> encoded);
// Superbundles are merged on several threads, and what each writes has to land
// where it would have had they been merged one after another. So each is merged
// with a waiting copy of the store. It reads as the store does and keeps what it
// is given, placing it in an archive index no file has (waiting_archive) at the
// offset within what it holds for that file. When every superbundle before its
// 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;
[[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;
}
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;
void shift(fb::CasIdentifier& location, std::uint32_t& offset,
const ArchivePlacement* placement) const;
private:
// Appends to one of the patch's own archives; returns where the bytes start.
std::uint64_t append(const fs::path& path, std::span<const std::byte> encoded);
[[nodiscard]] fs::path archive_path(std::uint32_t installChunk, std::uint16_t archive) const;
fs::path output_;
std::map<std::wstring, std::uint64_t> offsets_;
// A waiting store: what it holds for each archive file, the one archive index it
// was asked to write to, and where each file's bytes went once settled.
struct Held {
std::vector<std::byte> bytes;
std::uint64_t at{};
};
bool waiting_{};
std::optional<std::uint16_t> archive_;
std::map<std::wstring, Held> held_;
};
using ArchiveUse = std::set<std::pair<std::uint32_t, std::uint16_t>>;
@@ -224,11 +265,15 @@ struct AssetOverrides {
// The changes and additions asset mods (mods that add no levels) make to the
// game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an
// unreadable mod is noted and simply changes nothing elsewhere.
// unreadable mod is noted and simply changes nothing elsewhere. The mods' files are read on up
// to `threads` threads beside this one (`background`: at a priority under the game's, for a
// merge while it runs); what they mean together is then settled in priority order, so the
// result is the same whatever the count.
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report);
const fs::path& gameRoot, MergeReport& report,
std::size_t threads = 0, bool background = false);
// A bundle's chunk metadata is a list with one record for each of its chunks: the
// hash of the name of the resource the chunk belongs to ("h64") and what the
+294 -159
View File
@@ -4,152 +4,289 @@
#include "Engine/Resource/ebx_merge.h"
#include "Engine/Resource/ebx_writer.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <exception>
#include <mutex>
#include <set>
#include <stdexcept>
#include <thread>
namespace dingosdk::mods::detail {
namespace {
// The game's side of what every mod is held against, read once for all of them: forty
// cosmetic mods each patch the same few bundles, the largest with some 17,000 assets, and
// each used to list that bundle again for itself.
struct GameBundle {
std::optional<Listing> listing;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
std::map<std::string, const fb::BundleAsset*, std::less<>> scripts;
std::set<std::string, std::less<>> resources;
};
struct GameToc {
fb::TocDocument document;
std::map<std::string, const fb::TocBundle*, std::less<>> bundles;
std::set<fb::Guid> chunks;
};
// Made on first use by whichever thread asks first; the others wait for it. One that could
// not be read throws for each asker, as reading it for each did.
template <class Value> class Shared {
public:
template <class Make> const Value& get(const std::string& key, Make&& make) {
Entry* entry{};
{
std::lock_guard lock(mutex_);
entry = &entries_[key];
}
std::call_once(entry->once, [&] { make(entry->value); });
return entry->value;
}
private:
struct Entry {
std::once_flag once;
Value value;
};
std::mutex mutex_;
std::map<std::string, Entry, std::less<>> entries_; // nodes stay where they are
};
// The game's assets a mod changed. They wait until its additions are settled: one that
// another mod's asset of the same name stands in for is not where a copy can find it, and a
// change must not name it there.
struct Change {
std::string bundle; // lower case: where the mod changed it
std::string name; // lower case
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
struct Script {
std::string name; // lower case
fb::Sha1 game; // the game's copy it replaces
AssetOverride value;
};
// 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
// above it is settled afterwards, one mod at a time in priority order.
struct ModRead {
// This mod's new assets, and the partitions its recorded changes import.
// An added asset only follows a change that refers to it: copied on its
// own, it could arrive in another mod's bundle without the resources
// and chunks it was added with.
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
std::vector<bool> unreadable; // per candidate: never follows
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn; // the documents it added to each bundle, by guid
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
std::set<fb::Guid> imported;
std::vector<Change> changes;
std::vector<Script> scripts;
std::vector<std::string> notes;
std::exception_ptr failure;
};
ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, Shared<GameToc>& gameTocs, Shared<GameBundle>& gameBundles) {
ModRead read;
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod.directory / fs::path(relative)));
const auto tocKey = lower(relative);
const auto& game = gameTocs.get(tocKey, [&](GameToc& toc) {
toc.document = fb::read_toc(read_file(baseToc));
for (const auto& bundle : toc.document.bundles) toc.bundles.emplace(lower(bundle.name), &bundle);
for (const auto& chunk : toc.document.chunks) toc.chunks.insert(chunk.guid);
});
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !game.chunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto bundleName = lower(bundle.name);
const auto shipped = game.bundles.find(bundleName);
if (shipped == game.bundles.end()) continue;
const auto modListing = list_bundle(store, mod.directory, baseRoot, bundle, gameRoot);
const auto& known = gameBundles.get(tocKey + '|' + bundleName, [&](GameBundle& made) {
made.listing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!made.listing) return;
const auto& manifest = made.listing->manifest;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index)
made.original.emplace(lower(manifest.ebx[index].name), std::pair{manifest.ebx[index].sha1, index});
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
for (const auto& asset : manifest.resources) {
if (asset.resourceType == luaScriptResourceType) made.scripts.emplace(lower(asset.name), &asset);
made.resources.insert(lower(asset.name));
}
});
if (!modListing || !known.listing) continue;
const auto& gameListing = known.listing;
const auto& original = known.original;
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({bundleName, {mod.name, asset, payload(), tocKey}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
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});
}
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = known.scripts.find(name);
if (originalScript == known.scripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
// (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;
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}});
}
// 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).
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (known.resources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({bundleName, {mod.name, asset,
store.read(file.location.patch ? mod.directory : baseRoot, file.location, file.offset, file.size), tocKey}});
}
}
} catch (const std::exception& failed) {
notes.push_back(mod.name + ": " + relative +
": its changes could not be read for other mods' copies (" + failed.what() + ")");
}
}
// Each added document's guid, and the guids of the documents it refers to in turn.
unreadable.assign(candidates.size(), false);
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { unreadable[index] = true; }
}
return read;
}
} // namespace
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report) {
const fs::path& gameRoot, MergeReport& report,
std::size_t threads, bool background) {
AssetOverrides out;
// Highest priority first: an asset a higher mod already changed keeps that change.
// Where in `out.added` each bundle's addition of a kind and (lower-case) name is: looked up
// for every addition and companion, which a search of the list made quadratic.
std::map<std::string, std::map<std::pair<fb::AssetKind, std::string>, std::size_t>, std::less<>> addedAt;
// A map's edits to the game's assets serve its own levels; asset mods
// (cameras, tuning, cosmetics) are the ones meant to apply everywhere.
std::vector<std::pair<const Mod*, const RelativeFiles*>> asked;
for (const auto* mod : mods) {
// A map's edits to the game's assets serve its own levels; asset mods
// (cameras, tuning, cosmetics) are the ones meant to apply everywhere.
if (mod->provides_levels) continue;
const auto files = modFiles.find(mod);
if (files == modFiles.end()) continue;
std::size_t changed{};
// This mod's new assets, and the partitions its recorded changes import.
// An added asset only follows a change that refers to it: copied on its
// own, it could arrive in another mod's bundle without the resources
// and chunks it was added with.
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
std::set<fb::Guid> imported;
// The game's assets this mod changed. They wait until its additions are
// settled: one that another mod's asset of the same name stands in for
// is not where a copy can find it, and a change must not name it there.
struct Change {
std::string bundle; // lower case: where the mod changed it
std::string name; // lower case
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
std::vector<Change> changes;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files->second.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod->directory / fs::path(relative)));
const auto game = fb::read_toc(read_file(baseToc));
std::map<std::string, const fb::TocBundle*, std::less<>> gameBundles;
for (const auto& bundle : game.bundles) gameBundles.emplace(lower(bundle.name), &bundle);
std::set<fb::Guid> gameChunks;
for (const auto& chunk : game.chunks) gameChunks.insert(chunk.guid);
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !gameChunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto shipped = gameBundles.find(lower(bundle.name));
if (shipped == gameBundles.end()) continue;
const auto modListing = list_bundle(store, mod->directory, baseRoot, bundle, gameRoot);
const auto gameListing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!modListing || !gameListing) continue;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
for (std::size_t index = 0; index < gameListing->manifest.ebx.size(); ++index) {
const auto& asset = gameListing->manifest.ebx[index];
original.emplace(lower(asset.name), std::pair{asset.sha1, index});
}
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod->directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
// A higher mod's version of the asset, which this change is
// combined with below, may name what this mod adds.
if (const auto versions = out.changed.find(name); versions != out.changed.end())
if (const auto existing = versions->second.find(game_copy->second.first);
existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({lower(bundle.name), name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
}
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
std::map<std::string, const fb::BundleAsset*, std::less<>> gameScripts;
for (const auto& asset : gameListing->manifest.resources)
if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset);
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = gameScripts.find(name);
if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
auto& versions = out.scripts[name];
if (versions.contains(originalScript->second->sha1)) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
versions.emplace(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});
++changed;
}
// 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).
std::set<std::string, std::less<>> shippedResources;
for (const auto& asset : gameListing->manifest.resources) shippedResources.insert(lower(asset.name));
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (shippedResources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({lower(bundle.name), {mod->name, asset,
store.read(file.location.patch ? mod->directory : baseRoot, file.location, file.offset, file.size), lower(relative)}});
}
if (const auto files = modFiles.find(mod); files != modFiles.end()) asked.emplace_back(mod, &files->second);
}
// Read: every mod's files, several mods at a time.
Shared<GameToc> gameTocs;
Shared<GameBundle> gameBundles;
std::vector<ModRead> reads(asked.size());
{
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= asked.size()) return;
try {
reads[mine] = read_mod(*asked[mine].first, *asked[mine].second, store, baseRoot, gameRoot, gameTocs, gameBundles);
} catch (...) {
reads[mine].failure = std::current_exception();
}
} catch (const std::exception& failure) {
report.notes.push_back(mod->name + ": " + relative +
": its changes could not be read for other mods' copies (" + failure.what() + ")");
}
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < std::min(threads, asked.size()) && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
// Settle: highest priority first, an asset a higher mod already changed keeps that change.
for (std::size_t slot = 0; slot < asked.size(); ++slot) {
const auto* mod = asked[slot].first;
auto& read = reads[slot];
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)
if (const auto versions = out.changed.find(change.name); versions != out.changed.end())
if (const auto existing = versions->second.find(change.game); existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} 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));
++changed;
}
// 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
@@ -161,10 +298,10 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
std::set<fb::Guid> lost;
const auto keep = [&](const std::string& bundle, AssetAddition addition) {
auto& list = out.added[bundle];
const auto name = lower(addition.asset.name);
const auto holder = std::ranges::find_if(list, [&](const AssetAddition& other) {
return other.asset.kind == addition.asset.kind && lower(other.asset.name) == name; });
if (holder != list.end()) {
auto& index = addedAt[bundle];
const auto [place, fresh] = index.try_emplace({addition.asset.kind, lower(addition.asset.name)}, list.size());
if (!fresh) {
const auto holder = list.begin() + static_cast<std::ptrdiff_t>(place->second);
if (holder->mod != addition.mod && holder->asset.sha1 != addition.asset.sha1) {
auto& [count, example] = shadowed[holder->mod];
if (!count++) example = addition.asset.name;
@@ -178,18 +315,7 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
};
// Transitively: an addition a following addition imports follows too (a new song
// imports its new wave, and only the song is named by the changed playlist).
std::vector<bool> followed(candidates.size());
// The documents this mod added to each bundle, by guid.
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn;
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { followed[index] = true; } // unreadable: never follows
}
std::vector<bool> followed = unreadable; // (an unreadable one never follows)
for (bool grew = true; grew;) {
grew = false;
for (std::size_t index = 0; index < candidates.size(); ++index) {
@@ -204,11 +330,10 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
// Looked up, not made: a bundle with nothing carried must stay out of `added`.
const auto found = out.added.find(bundle);
if (found == out.added.end()) continue;
const auto name = lower(companion.asset.name);
if (std::ranges::any_of(found->second, [&](const AssetAddition& addition) {
return addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx &&
lower(addition.asset.name) == name; }))
keep(bundle, std::move(companion));
// One addition per kind and name in a bundle (keep): this mod's, or not.
const auto& index = addedAt[bundle];
const auto held = index.find({fb::AssetKind::ebx, lower(companion.asset.name)});
if (held != index.end() && found->second[held->second].mod == mod->name) keep(bundle, std::move(companion));
}
for (auto& change : changes) {
// What the change names of the assets the mod added beside it. The mod
@@ -282,15 +407,25 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
if (addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx) try {
carried.push_back(fb::decode_cas(addition.encoded, {gameRoot}));
} catch (const std::exception&) {}
std::set<fb::Guid> taken;
// Each carried asset is gone through once, for all the chunks together: looking for
// every chunk in every asset in turn took most of a merge with many of either.
std::set<fb::Guid> wanted, named, taken;
std::vector<bool> starts(0x10000); // the two bytes a wanted chunk's id starts with
for (const auto& chunk : newChunks) {
const auto& id = chunk.guid.bytes;
if (!taken.contains(chunk.guid) && std::ranges::any_of(carried, [&](const std::vector<std::byte>& bytes) {
return std::search(bytes.begin(), bytes.end(), id.begin(), id.end()) != bytes.end(); })) {
out.chunks[mod->name].push_back(chunk);
taken.insert(chunk.guid);
}
wanted.insert(chunk.guid);
starts[std::to_integer<std::size_t>(chunk.guid.bytes[0]) << 8 |
std::to_integer<std::size_t>(chunk.guid.bytes[1])] = true;
}
for (const auto& bytes : carried)
for (std::size_t at = 0; at + sizeof(fb::Guid) <= bytes.size(); ++at) {
if (!starts[std::to_integer<std::size_t>(bytes[at]) << 8 | std::to_integer<std::size_t>(bytes[at + 1])])
continue;
fb::Guid id;
std::memcpy(id.bytes.data(), bytes.data() + at, id.bytes.size());
if (wanted.contains(id)) named.insert(id);
}
for (const auto& chunk : newChunks)
if (named.contains(chunk.guid) && taken.insert(chunk.guid).second) out.chunks[mod->name].push_back(chunk);
if (!taken.empty())
report.notes.push_back(mod->name + ": " + std::to_string(taken.size()) +
" added chunk(s) follow its added assets into other mods' superbundles");
+1
View File
@@ -201,6 +201,7 @@ ScoringCheck check_scoring(const Catalog& catalog, std::vector<std::string>* not
result.assets.push_back(key.substr(4));
for (const auto& sha1 : versions) hash = fnv(hash, key + ' ' + hex(sha1) + '\n');
}
std::ranges::sort(result.assets); // "ebx "/"res " keys sort apart once the prefix is gone
result.assets.erase(std::unique(result.assets.begin(), result.assets.end()), result.assets.end());
result.fingerprint = hash ? hash : 1;
} catch (const std::exception& failure) {
+355 -30
View File
@@ -2,9 +2,17 @@
#include "mod_catalog.h"
#include "mod_merge_internal.h"
#include "Engine/Resource/cas_codec.h"
#include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/mesh_set.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <mutex>
#include <thread>
#include <stdexcept>
namespace dingosdk::mods {
@@ -21,6 +29,17 @@ bool item_name(std::string_view name) {
name.substr(slash == std::string_view::npos ? 0 : slash + 1).starts_with("own_");
}
// The game's whole costumes, the ones a skater wears instead of clothes: where their items
// are, and how a costume's own bundle is named after the preset its item is fitted on (an
// "asset 1" shape). The bundle holds the costume's mesh, a MeshSet resource; its coarser levels
// of detail are chunks of the bundle and the finest is one of the superbundle's own.
constexpr std::string_view costume_items = "items/cust_fullbodycostume/";
constexpr std::string_view costume_shape = "asset 1 ";
constexpr std::string_view costume_bundle_suffix = "_cas_main_bundlereftable";
constexpr std::uint32_t mesh_set_type = 0x49b156d4;
// The levels of detail of a mod's mesh that are read: a copy has the finest ones.
constexpr std::size_t mod_mesh_lods = 3;
// An item's AssetPaths say what kind of asset each one is: 1 a morph preset and
// 2 a base mesh, which are what the item is fitted on; every other kind (3, an
// appearance preset, for nearly all of them) is what it looks like. Of the
@@ -175,6 +194,48 @@ public:
layout_(vfs::read_layout(baseRoot_ / L"layout.toc")),
store_(baseRoot_, catalog.root / generated_folder, layout_.root), notes_(notes) {}
// The meshes a mod ships, each level of detail read as a mesh of its own: every MeshSet in
// a bundle of its own files, whatever it is called and whether an item names it.
struct Mesh {
std::string name;
std::vector<std::uint64_t> points;
};
[[nodiscard]] std::vector<Mesh> read_mod_meshes(const Mod& mod) {
std::vector<Mesh> meshes;
for (const auto& relative : scan(mod.directory).tocs) {
fb::TocDocument own;
try {
own = fb::read_toc(read_file(mod.directory / fs::path(relative)));
} catch (const std::exception&) {
continue; // (read_mod notes it)
}
for (const auto& bundle : own.bundles) {
try {
const auto region = fb::read_bundle_region(bundle.region);
if (std::none_of(region.files.begin(), region.files.end(),
[](const fb::BundleFileInfo& file) { return file.location.patch; }))
continue;
const auto listing = list_bundle(store_, mod.directory, baseRoot_, bundle, gameRoot_);
if (!listing) continue;
const auto& manifest = listing->manifest;
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
const auto at = listing->first + manifest.ebx.size() + index;
if (manifest.resources[index].resourceType != mesh_set_type || at >= listing->files.size() ||
!listing->files[at].location.patch)
continue;
read_mesh(mod.directory, *listing, manifest.ebx.size() + index, own, mod_mesh_lods,
[&](std::vector<std::uint64_t> points) {
meshes.push_back({manifest.resources[index].name, std::move(points)});
});
}
} catch (const std::exception& failure) {
note(mod.name + ": " + bundle.name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
}
}
}
return meshes;
}
// The items a mod adds to the game's bundles, and the game's own it changes.
// An item only loads from a bundle the game loads, and the lists the game
// reads its items from are in one of those, so a mod's own bundles (a map's
@@ -226,38 +287,126 @@ public:
return items;
}
// Every item the game ships; how many were read.
[[nodiscard]] std::size_t read_game(StoreItems& items, const StoreItem& sold) {
// Every item the game ships, and with `meshes` the meshes its items have: the whole
// costumes' under their items, every other one as nobody's (a costume is partly made of
// clothes anyone has). How many items were read, and how many costumes' meshes.
//
// Each of the game's item bundles is read by itself, on up to `threads` threads beside
// this one; what was read is then filed in the bundles' own order, so a part two items
// share is the same item's whatever the count.
struct GameCounts {
std::size_t items{}, costumes{};
};
[[nodiscard]] GameCounts read_game(StoreItems& items, const StoreItem& sold, StoreMeshes* meshes, std::size_t threads,
bool background) {
const auto* toc = game_toc(items_toc);
if (!toc) return 0;
std::size_t read{};
for (const auto& [name, bundle] : *toc) {
if (!toc) return {};
const auto& document = tocs_.at(lower(items_toc)).document;
struct Read {
struct Item {
ItemContent content;
bool costume{}; // one of the game's whole costumes, by where its asset is
};
std::vector<Item> items;
struct Mesh {
std::string resource;
std::vector<std::vector<std::uint64_t>> levels;
};
std::vector<Mesh> meshes;
std::string note;
};
std::vector<const fb::TocBundle*> bundles;
for (const auto& [name, bundle] : *toc) bundles.push_back(bundle);
std::vector<Read> reads(bundles.size());
const auto read_bundle = [&](std::size_t at) {
auto& read = reads[at];
try {
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_);
if (!listing) continue;
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index) {
if (!item_name(lower(listing->manifest.ebx[index].name))) continue;
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundles[at], gameRoot_);
if (!listing) return;
const auto& manifest = listing->manifest;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index) {
const auto name = lower(manifest.ebx[index].name);
if (!item_name(name)) continue;
try {
const auto document = fb::ebx::read_document(
read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
const auto item = item_content(document);
if (!item) continue;
items.add(*item, sold(lower(item->key)));
++read;
const auto asset = fb::ebx::read_document(read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
if (auto item = item_content(asset)) read.items.push_back({std::move(*item), name.starts_with(costume_items)});
} catch (const std::exception&) {}
}
if (!meshes) return;
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
if (manifest.resources[index].resourceType != mesh_set_type) continue;
Read::Mesh mesh{manifest.resources[index].name, {}};
read_mesh(baseRoot_, *listing, manifest.ebx.size() + index, document, 16,
[&](std::vector<std::uint64_t> points) { mesh.levels.push_back(std::move(points)); });
if (!mesh.levels.empty()) read.meshes.push_back(std::move(mesh));
}
} catch (const std::exception& failure) {
note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
read.note = "The game's " + bundles[at]->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")";
}
};
{
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= bundles.size()) return;
read_bundle(mine);
}
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < threads && bundles.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
GameCounts counts;
for (auto& read : reads) {
if (!read.note.empty()) note(std::move(read.note));
for (const auto& item : read.items) {
const bool store = sold(lower(item.content.key));
items.add(item.content, store);
++counts.items;
if (!item.costume) continue;
for (const auto& shape : item.content.shapes)
if (shape.starts_with(costume_shape))
costumes_.insert_or_assign(shape.substr(costume_shape.size()), Costume{item.content.key, store});
}
}
return read;
if (!meshes || costumes_.empty()) return counts;
// A costume's own bundle is named after the preset its item is fitted on.
for (std::size_t at = 0; at < bundles.size(); ++at) {
const auto name = lower(bundles[at]->name);
const Costume* costume{};
if (name.ends_with(costume_bundle_suffix)) {
const auto stem = name.substr(0, name.size() - costume_bundle_suffix.size());
const auto slash = stem.rfind('/');
const auto found = costumes_.find(stem.substr(slash == std::string::npos ? 0 : slash + 1));
if (found != costumes_.end()) costume = &found->second;
}
for (const auto& mesh : reads[at].meshes)
for (const auto& level : mesh.levels) {
// (Each other mesh under its own name, so two of them sharing a point is also seen.)
if (costume) meshes->add(costume->key, costume->sold, level);
else meshes->add("mesh " + mesh.resource, false, level);
}
counts.costumes += costume && !reads[at].meshes.empty();
}
return counts;
}
private:
using Bundles = std::map<std::string, const fb::TocBundle*, std::less<>>;
using Assets = std::map<std::string, fb::Sha1, std::less<>>;
// (Both of these are asked by several threads while the mods are read: one at a time,
// and what they hand out stays where it is.)
const Bundles* game_toc(std::string_view relative) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative);
if (const auto found = tocs_.find(key); found != tocs_.end())
return found->second.index.empty() ? nullptr : &found->second.index;
@@ -278,6 +427,7 @@ private:
// The game's copy of a bundle: each EBX it holds, to tell a mod's additions
// and changes from what it carries untouched. Null when it cannot be read.
const Assets* game_assets(std::string_view relative, const fb::TocBundle& bundle) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative) + '|' + lower(bundle.name);
if (const auto found = assets_.find(key); found != assets_.end())
return found->second ? &*found->second : nullptr;
@@ -289,8 +439,44 @@ private:
return &*entry;
}
// One MeshSet (the listing's `file`th file) as points, a level of detail at a time, up to
// `lods` of them. A level's geometry is a chunk of the bundle or of its superbundle `toc`. A
// mesh or a level this cannot read is left out: most are not laid out as a costume's is.
template <class Each>
void read_mesh(const fs::path& root, const Listing& listing, std::size_t file, const fb::TocDocument& toc, std::size_t lods,
Each&& each) {
std::vector<std::byte> resource;
std::size_t count{};
try {
resource = read_asset(store_, root, baseRoot_, listing, file, gameRoot_);
count = std::min(fb::mesh_lod_count(resource), lods);
} catch (const std::exception&) {
return;
}
const auto& manifest = listing.manifest;
for (std::size_t lod = 0; lod < count; ++lod) {
try {
const auto wanted = fb::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_, root, baseRoot_, listing, manifest.ebx.size() + manifest.resources.size() + chunk, gameRoot_);
for (auto chunk = toc.chunks.begin(); chunk != toc.chunks.end() && geometry.empty(); ++chunk)
if (chunk->guid == wanted && !chunk->removed)
geometry = fb::decode_cas(store_.read(chunk->location.patch ? root : baseRoot_, chunk->location,
chunk->offset, chunk->size), {gameRoot_});
if (geometry.empty()) continue;
auto points = mesh_points(fb::read_mesh_positions(resource, lod, geometry));
if (!points.empty()) each(std::move(points));
} catch (const std::exception&) {}
}
}
// What a mod's reading has to say is kept with that mod while the mods are read at once
// (notes_for), so the notes come out in the mods' order however the threads ran.
void note(std::string text) {
if (notes_) notes_->push_back(std::move(text));
if (note_sink) note_sink->push_back(std::move(text));
else if (notes_) notes_->push_back(std::move(text));
}
struct GameToc {
@@ -303,6 +489,28 @@ private:
std::vector<std::string>* notes_;
std::map<std::string, GameToc, std::less<>> tocs_;
std::map<std::string, std::optional<Assets>, std::less<>> assets_;
std::mutex cache_mutex_;
static inline thread_local std::vector<std::string>* note_sink = nullptr;
public:
// While one lives, this thread's notes go into `kept`.
struct NotesFor {
explicit NotesFor(std::vector<std::string>& kept) { note_sink = &kept; }
~NotesFor() { note_sink = nullptr; }
NotesFor(const NotesFor&) = delete;
NotesFor& operator=(const NotesFor&) = delete;
};
void say(std::vector<std::string>& kept) {
if (notes_) notes_->insert(notes_->end(), std::make_move_iterator(kept.begin()), std::make_move_iterator(kept.end()));
}
private:
// The game's whole costumes: the preset each is fitted on -> its item.
struct Costume {
std::string key;
bool sold{};
};
std::map<std::string, Costume, std::less<>> costumes_;
};
} // namespace
@@ -314,6 +522,7 @@ std::optional<ItemContent> item_content(const fb::ebx::Document& document) {
if (!key || key->empty() || !data) return std::nullopt;
ItemContent item;
item.key = *key;
if (const auto* name = text_of(*root->object, "Name")) item.costume = lower(*name).starts_with(costume_items);
std::string text;
ItemReader reader(document, item, text);
reader.value(data->value);
@@ -338,8 +547,9 @@ void StoreItems::add(const ItemContent& item, bool sold) {
else parts.free.insert(part);
};
for (const auto& look : item.looks) file(looks_, look);
// A store item with a look is told by that; its shape is anyone's to use.
if (!sold || item.looks.empty())
// A store item with a look is told by that; its shape is anyone's to use. A whole
// costume's is not: the shape is the costume's own mesh, with or without looks on it.
if (!sold || item.looks.empty() || item.costume)
for (const auto& shape : item.shapes) file(shapes_, shape);
if (!item.data.empty()) file(data_, item.data);
}
@@ -347,34 +557,149 @@ void StoreItems::add(const ItemContent& item, bool sold) {
std::string StoreItems::original(const ItemContent& item) const {
for (const auto& look : item.looks)
if (const auto* key = looks_.owner(look)) return *key;
if (item.looks.empty())
for (const auto& shape : item.shapes)
if (const auto* key = shapes_.owner(shape)) return *key;
// (Only a store item with no look files its shapes: a whole costume. A look of the
// mod's own over one does not make the costume the mod's.)
for (const auto& shape : item.shapes)
if (const auto* key = shapes_.owner(shape)) return *key;
if (!item.data.empty())
if (const auto* key = data_.owner(item.data)) return *key;
return {};
}
StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector<std::string>* notes) noexcept {
std::uint64_t mesh_point(const std::array<float, 3>& position) noexcept {
// Half a millimetre a step, 21 bits an axis: a kilometre either way, far past any skater.
std::uint64_t point{};
for (const float coordinate : position) {
const auto step = std::isfinite(coordinate) ? std::llround(std::clamp(coordinate, -500.0f, 500.0f) * 2000.0f) : 0;
point = point << 21 | (static_cast<std::uint64_t>(step + (1 << 20)) & 0x1fffff);
}
return point;
}
std::vector<std::uint64_t> mesh_points(const std::vector<std::array<float, 3>>& positions) {
std::vector<std::uint64_t> points;
points.reserve(positions.size());
for (const auto& position : positions) points.push_back(mesh_point(position));
std::sort(points.begin(), points.end());
points.erase(std::unique(points.begin(), points.end()), points.end());
return points;
}
void StoreMeshes::add(std::string_view key, bool sold, const std::vector<std::uint64_t>& points) {
auto found = by_key_.find(key);
if (found == by_key_.end()) {
found = by_key_.emplace(std::string(key), static_cast<std::uint32_t>(owners_.size())).first;
owners_.push_back({std::string(key), sold, 0});
}
const auto owner = found->second;
owners_[owner].largest = std::max(owners_[owner].largest, points.size());
for (const auto point : points) {
const auto [place, fresh] = points_.try_emplace(point, owner);
// A second mesh has it too: it is neither's.
if (!fresh && place->second != owner) place->second = nobody;
}
}
std::string StoreMeshes::original(const std::vector<std::uint64_t>& points) const {
if (points.size() < shared_least) return {};
std::vector<std::size_t> shared(owners_.size());
for (const auto point : points)
if (const auto found = points_.find(point); found != points_.end() && found->second != nobody) ++shared[found->second];
const std::string* most{};
std::size_t count{};
for (std::size_t owner = 0; owner < owners_.size(); ++owner) {
const auto& costume = owners_[owner];
if (!costume.sold || shared[owner] < shared_least || shared[owner] <= count) continue;
if (shared[owner] * 10 >= costume.largest || shared[owner] * 10 >= points.size() * 3) most = &costume.key, count = shared[owner];
}
return most ? *most : std::string();
}
StoreMeshes::Closest StoreMeshes::closest(const std::vector<std::uint64_t>& points) const {
std::vector<std::size_t> shared(owners_.size());
for (const auto point : points)
if (const auto found = points_.find(point); found != points_.end() && found->second != nobody) ++shared[found->second];
Closest most;
for (std::size_t owner = 0; owner < owners_.size(); ++owner)
if (owners_[owner].sold && shared[owner] > most.shared) most = {owners_[owner].key, shared[owner], owners_[owner].largest};
return most;
}
StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector<std::string>* notes,
std::size_t threads, bool background) noexcept {
StoreCopies result;
try {
if (!sold || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; }))
return result;
Scan scan(catalog, notes);
std::vector<std::pair<const Mod*, std::vector<ItemContent>>> added;
for (const auto& mod : catalog.mods) {
if (!mod.provides_layout) continue;
if (auto items = scan.read_mod(mod); !items.empty()) added.emplace_back(&mod, std::move(items));
struct Added {
const Mod* mod{};
std::vector<ItemContent> items;
std::vector<Scan::Mesh> meshes;
};
std::vector<Added> added;
{
// Each mod is read by itself, several at a time; they are gone through in their own
// order afterwards.
std::vector<const Mod*> asked;
for (const auto& mod : catalog.mods)
if (mod.provides_layout) asked.push_back(&mod);
std::vector<Added> reads(asked.size());
std::vector<std::vector<std::string>> said(asked.size());
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= asked.size()) return;
const Scan::NotesFor kept(said[mine]);
try {
reads[mine] = {asked[mine], scan.read_mod(*asked[mine]), scan.read_mod_meshes(*asked[mine])};
} catch (...) {
reads[mine] = {asked[mine], {}, {}}; // (neither reader throws; a mod that cannot be read adds nothing)
}
}
};
{
std::vector<std::jthread> workers;
try {
for (std::size_t index = 0; index < threads && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
for (std::size_t index = 0; index < asked.size(); ++index) {
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]));
}
}
if (added.empty()) return result;
// The costumes' meshes are only read when a mod ships a mesh at all.
StoreItems game;
result.game_items = scan.read_game(game, sold);
StoreMeshes costumes;
const auto counts = scan.read_game(game, sold, result.mod_meshes ? &costumes : nullptr, threads, background);
result.game_items = counts.items;
result.game_costumes = counts.costumes;
if (!result.game_items) {
if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run");
return result;
}
for (const auto& [mod, items] : added) {
for (const auto& [mod, items, meshes] : added) {
StoreCopies::Source source{mod->name};
for (const auto& mesh : meshes) {
result.meshes_seen.push_back({mod->name, mesh.name, mesh.points.size(), costumes.closest(mesh.points)});
const auto original = costumes.original(mesh.points);
if (original.empty()) continue;
if (!source.count++) {
source.example = mesh.name;
source.original = original;
source.by_mesh = true;
}
result.items.insert_or_assign(lower(mesh.name), lower(original));
}
for (const auto& item : items) {
const auto key = lower(item.key);
// A store item under its own key is held by that key already.
+68 -4
View File
@@ -1,6 +1,8 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
@@ -46,6 +48,9 @@ struct ItemContent {
// nothing.
std::vector<std::string> shapes;
std::string data; // a digest of every field of its item data, to compare whole
// A whole costume, worn instead of clothes (its asset is one of the game's full-body
// costumes). Its shape is the costume, whatever looks it also names.
bool costume{};
};
// Empty when the document is not an item (an asset with a Key and ItemData).
[[nodiscard]] std::optional<ItemContent> item_content(const frostbite::ebx::Document& document);
@@ -56,9 +61,10 @@ public:
// `sold`: the store sells it, so ReSkate never hands it out.
void add(const ItemContent& item, bool sold);
// The key of the store item `item` is a copy of, or empty. It is one when
// it has a look only a store item has, when it has no look of its own and
// is fitted on a shape only a store item with no look has (a whole
// costume), or when its item data is a store item's field for field.
// it has a look only a store item has, when it is fitted on a shape only
// a store item with no look has (a whole costume, with or without a look
// of the mod's own over it), or when its item data is a store item's
// field for field.
// Anything an item the store does not sell has as well is nobody's.
[[nodiscard]] std::string original(const ItemContent& item) const;
@@ -71,6 +77,47 @@ private:
Parts looks_, shapes_, data_;
};
// A costume is its mesh more than anything it names, and a mesh can be shipped again under any
// name, whole or in part, in an item or beside one. So the meshes a mod ships are also held
// against the geometry of the costumes the store sells: where their vertices are. A vertex is
// told by its place to half a millimetre.
[[nodiscard]] std::uint64_t mesh_point(const std::array<float, 3>& position) noexcept;
// One level of detail of a mesh, as the places of its vertices: sorted, each once.
[[nodiscard]] std::vector<std::uint64_t> mesh_points(const std::vector<std::array<float, 3>>& positions);
class StoreMeshes final {
public:
// One level of detail of one of the game's meshes. `key`: the costume's item; any other
// mesh of the game's (clothes, a free costume) is added as not sold, under any key.
void add(std::string_view key, bool sold, const std::vector<std::uint64_t>& points);
[[nodiscard]] bool empty() const noexcept { return owners_.empty(); }
// The key of the store costume a mesh with these points takes from, or empty. It does when
// enough of them (shared_least) are points only that one costume has, and they are a tenth
// of that costume's finest level or three tenths of the mesh. A point two of the game's
// meshes have is nobody's: the body under two costumes, the socks one wears that are also
// an item anyone has.
[[nodiscard]] std::string original(const std::vector<std::uint64_t>& points) const;
static constexpr std::size_t shared_least = 300;
// For a report: the store costume most of these points are only in, and how many are, whether
// or not that is enough to call the mesh taken from it (empty key: none are any costume's).
struct Closest {
std::string key;
std::size_t shared{}, costume_points{};
};
[[nodiscard]] Closest closest(const std::vector<std::uint64_t>& points) const;
private:
static constexpr std::uint32_t nobody = 0xffffffffU;
struct Owner {
std::string key;
bool sold{};
std::size_t largest{}; // its finest level of detail, in points
};
std::vector<Owner> owners_;
std::map<std::string, std::uint32_t, std::less<>> by_key_;
std::map<std::uint64_t, std::uint32_t> points_; // -> its one owner, or nobody
};
struct StoreCopies {
// Lower-case key of each copy -> the lower-case key of the store item it copies.
std::map<std::string, std::string, std::less<>> items;
@@ -79,10 +126,23 @@ struct StoreCopies {
std::string mod;
std::size_t count{};
std::string example, original; // one of its copies and what that copies, as written
// How that one was found: its mesh has the costume's geometry, or its item is fitted on
// what the costume is (its shape, or a store item's looks or data).
bool by_mesh{};
};
std::vector<Source> mods;
// Every mesh level a mod ships and how much of a store costume's geometry is in it, for a
// report of the check (the Paid Cosmetics Detector, the test tool): a copy or not.
struct MeshSeen {
std::string mod, mesh;
std::size_t points{};
StoreMeshes::Closest closest;
};
std::vector<MeshSeen> meshes_seen;
// 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.
std::size_t mod_meshes{}, game_costumes{};
};
// Whether the store sells the item with this lower-case key.
@@ -92,7 +152,11 @@ using StoreItem = std::function<bool(const std::string&)>;
// and holds them against the game's own. The game's are only read when a mod
// has an item at all. Never throws: a bundle that cannot be read is left out
// (the merge leaves its mod out of the game too) and noted in `notes`.
// The game's own files are read on up to `threads` threads beside the caller's
// (`background`: at a priority under the game's, for a merge while it runs); the verdicts do
// not depend on the count.
[[nodiscard]] StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold,
std::vector<std::string>* notes = nullptr) noexcept;
std::vector<std::string>* notes = nullptr, std::size_t threads = 0,
bool background = false) noexcept;
} // namespace dingosdk::mods
+123
View File
@@ -0,0 +1,123 @@
#include "thunderstore_package.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
namespace dingosdk::thunderstore_package {
namespace {
constexpr char site[] = "https://thunderstore.io";
bool word(std::string_view text) {
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(), text.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
});
}
bool version(std::string_view text) {
unsigned numbers{};
for (std::size_t at = 0; at <= text.size();) {
const auto end = std::min(text.find('.', at), text.size());
const auto number = text.substr(at, end - at);
if (number.empty() || number.size() > 9 || !std::all_of(number.begin(), number.end(), [](char c) { return c >= '0' && c <= '9'; }))
return false;
++numbers;
at = end + 1;
}
return numbers == 3;
}
bool same(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) {
const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c; };
return lower(x) == lower(y);
});
}
std::wstring wide(const std::string& text) { return std::wstring(text.begin(), text.end()); } // ASCII only here
std::string text_of(const Json& object, std::string_view key) {
return object.is_object() && object.contains(key) && object.at(key).is_string() ? object.at(key).string() : std::string();
}
bool flag(const Json& object, std::string_view key) {
return object.is_object() && object.contains(key) && object.at(key).is_boolean() && object.at(key).get<bool>();
}
} // namespace
std::optional<Name> parse_name(std::string_view package) {
const auto first = package.find('-');
if (first == std::string_view::npos) return std::nullopt;
const auto rest = package.substr(first + 1);
const auto second = rest.find('-');
Name name{std::string(package.substr(0, first)), std::string(rest.substr(0, second)),
second == std::string_view::npos ? std::string() : std::string(rest.substr(second + 1))};
if (!word(name.owner) || !word(name.name) || (!name.version.empty() && !version(name.version))) return std::nullopt;
return name;
}
std::wstring details_url(const Name& name) {
return wide(std::string(site) + "/api/experimental/package/" + name.owner + '/' + name.name + '/');
}
std::wstring download_url(const Name& name, std::string_view wanted) {
return wide(std::string(site) + "/package/download/" + name.owner + '/' + name.name + '/' + std::string(wanted) + '/');
}
Choice choose(std::string_view json, const Name& wanted, std::string_view community) {
Choice choice;
try {
const auto root = Json::parse(json);
if (!root.is_object() || !same(text_of(root, "namespace"), wanted.owner) || !same(text_of(root, "name"), wanted.name)) {
choice.reason = "Thunderstore does not have that package.";
return choice;
}
if (flag(root, "is_deprecated")) {
choice.reason = "Its package on Thunderstore is marked deprecated.";
return choice;
}
bool listed{}, map{}, rejected{}, nsfw{};
if (root.contains("community_listings") && root.at("community_listings").is_array())
for (const auto& listing : root.at("community_listings")) {
if (!same(text_of(listing, "community"), community)) continue;
listed = true;
rejected = same(text_of(listing, "review_status"), "rejected");
nsfw = flag(listing, "has_nsfw_content");
if (listing.contains("categories") && listing.at("categories").is_array())
for (const auto& category : listing.at("categories"))
{
map = map || (category.is_string() && same(category.string(), "Maps"));
nsfw = nsfw || (category.is_string() && same(category.string(), "NSFW"));
}
}
if (!listed) { choice.reason = "Its package is not listed for ReSkate on Thunderstore."; return choice; }
if (rejected) { choice.reason = "Its package was rejected by Thunderstore's moderators."; return choice; }
if (nsfw) { choice.reason = "Its package is marked NSFW on Thunderstore."; return choice; }
if (!map) { choice.reason = "Its package is not listed as a map on Thunderstore."; return choice; }
const auto newest = root.contains("latest") ? text_of(root.at("latest"), "version_number") : std::string();
if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; }
choice.newest = newest;
// Its own words about itself, kept to printable text of a card's length.
for (const unsigned char c : text_of(root.at("latest"), "description")) {
if (choice.description.size() >= 240) { choice.description += "..."; break; }
choice.description += c < 0x20 || c == 0x7f ? ' ' : static_cast<char>(c);
}
{
// https://<something>.thunderstore.io/...png, and nothing that is not a plain URL.
const auto icon = text_of(root.at("latest"), "icon");
constexpr std::string_view scheme = "https://";
const auto path = icon.find('/', scheme.size());
const auto host = icon.starts_with(scheme) && path != std::string::npos ? std::string_view(icon).substr(scheme.size(), path - scheme.size())
: std::string_view{};
const bool plain = icon.size() < 400 && std::all_of(icon.begin(), icon.end(), [](unsigned char c) {
return c > 0x20 && c < 0x7f && c != '"' && c != '<' && c != '>' && c != '\\' && c != '#' && c != '?' && c != '@';
});
if (plain && (host == "thunderstore.io" || host.ends_with(".thunderstore.io"))) choice.icon = icon;
}
choice.version = wanted.version.empty() ? newest : wanted.version;
choice.ok = true;
} catch (const std::exception&) {
choice = {};
choice.reason = "Thunderstore's answer could not be read.";
}
return choice;
}
} // namespace dingosdk::thunderstore_package
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <optional>
#include <string>
#include <string_view>
// One Thunderstore package looked up by name, for the game: the map a multiplayer host is on
// and this player does not have (Extension/Assets/map_download.h). No network here; the caller
// fetches details_url() and hands the answer to choose().
namespace dingosdk::thunderstore_package {
struct Name {
std::string owner, name;
std::string version; // "1.2.3", or empty for the newest
// The Mods folder the package installs as, and how its files name it.
[[nodiscard]] std::string folder() const { return owner + '-' + name; }
bool operator==(const Name&) const = default;
};
// "Owner-Name" or "Owner-Name-1.2.3", as a host names a map's package
// (Extension/Multiplayer/Net/protocol.h: valid_map_package). Nothing for anything else.
std::optional<Name> parse_name(std::string_view package);
// /api/experimental/package/<owner>/<name>/: what Thunderstore says of the package.
std::wstring details_url(const Name& name);
// Where a version of it downloads from. Always thunderstore.io, whatever an answer says.
std::wstring download_url(const Name& name, std::string_view version);
struct Choice {
bool ok{};
std::string version; // the one to download: the one asked for, else the newest
std::string newest; // the newest, to fall back on when the one asked for is gone
// What its page says of it, for the player: one line of plain text, cut to a few lines' worth.
std::string description;
// Its picture, a PNG on Thunderstore's own hosts; empty when the answer names anything else.
std::string icon;
std::string reason; // why not, for the player
};
// Reads that answer. The package is taken only when it is the one asked for, is listed in
// `community` as a map (the "Maps" category), is not deprecated or marked NSFW there and was
// not rejected by the community's moderators.
Choice choose(std::string_view json, const Name& wanted, std::string_view community);
} // namespace dingosdk::thunderstore_package
+19 -1
View File
@@ -37,6 +37,7 @@ std::atomic<bool> merging{};
std::atomic<bool> reload_after{}, reload_ready{};
std::mutex state_mutex;
std::string last_status;
Progress merge_progress; // under state_mutex, while merging
std::optional<std::vector<std::filesystem::path>> pending_levels;
std::optional<std::vector<std::string>> applied; // set by a live apply; the launch's list before one
std::string current_level;
@@ -345,7 +346,11 @@ void apply_now() {
mods::MergeReport report;
std::string left_out;
for (int round = 0;; ++round) {
report = mods::merge_mods(catalog, {}, {.live = true});
// The mods in the game now were checked against the store when they went in.
report = mods::merge_mods(catalog, [](const mods::MergeProgress &now) {
std::lock_guard lock(state_mutex);
merge_progress = {now.done, now.total, now.step};
}, {.live = true, .checked = before});
if (!report.issue.empty()) break;
bool removed = false;
for (std::size_t i = catalog.mods.size(); i-- > 0;) {
@@ -431,11 +436,15 @@ void apply_now() {
std::lock_guard lock(state_mutex);
// Set with the manifests, so the client tick sees both in one update.
if (needed) reload_ready = true;
mods::board_break_applied(catalog);
pending_levels = mods::level_manifest_paths(catalog);
applied.emplace();
for (const auto &mod : catalog.mods) applied->push_back(mod.name);
}
if (!left_out.empty()) problem += (problem.empty() ? "left out " : "; left out ") + left_out + ", which could not be merged cleanly";
// Where the time went, step by step, as the launch's merge logs it.
for (const auto &note : report.notes)
if (note.starts_with("Merge times:")) logging::log(logging::Level::info, logging::Channel::assets, "Live mods: {}", note);
const auto elapsed = static_cast<double>(GetTickCount64() - started) / 1000.0;
set_status("applied " + std::to_string(catalog.mods.size()) + " mod(s) in " + std::to_string(elapsed).substr(0, 4) + "s; " +
(needed ? "reloading the level"
@@ -462,6 +471,10 @@ void run() {
std::string apply(bool reload_level) {
if (!layout_objects().manager) return "No merged mod patch was mounted at launch; restart the game to load mods.";
if (merging.exchange(true)) return "Mods are already being applied.";
{
std::lock_guard lock(state_mutex);
merge_progress = {};
}
reload_after = reload_level;
std::thread(run).detach();
return reload_level ? "Applying mods; the level reloads when they are ready, unless only maps changed."
@@ -511,6 +524,11 @@ ApplyEffect preview(const mods::ModList &list) {
return reload ? ApplyEffect::reload_level : ApplyEffect::maps_only;
}
bool busy() noexcept { return merging.load(); }
Progress progress() {
std::lock_guard lock(state_mutex);
return merge_progress;
}
bool only_maps(const std::filesystem::path& directory) { return scan_maps_only(directory); }
std::string status() {
std::lock_guard lock(state_mutex);
return last_status;
+9
View File
@@ -47,6 +47,12 @@ inline const char* apply_label(ApplyEffect effect) {
void set_current_level(std::string level);
// True while a live merge runs.
bool busy() noexcept;
// How far it is: steps done of all, and the step in hand ("Merging items from 12 mods").
struct Progress {
std::size_t done{}, total{};
std::string step;
};
Progress progress();
// The last live apply's outcome for the overlay, or empty before the first.
std::string status();
// Folder names of the mods in effect now: the launch's, or the last live apply's.
@@ -54,6 +60,9 @@ std::vector<std::string> applied_mods();
// True when the launch's merge placed this mod's archives (enabled or not),
// so it can be enabled without a restart.
bool placed_at_launch(const std::string& name);
// Whether a mod folder holds maps and nothing else: level bundles and the two files maps
// register themselves in (its layout and globals). Reads the folder; not cached.
bool only_maps(const std::filesystem::path& directory);
// reskate-levels.json of every mod a live apply left enabled, highest
// priority first; handed out once per successful apply.
std::optional<std::vector<std::filesystem::path>> take_level_manifests();
+3 -2
View File
@@ -47,7 +47,7 @@ struct State {
// Hooks and the native mount live until process exit, like the pinned runtime.
State& state() { static auto* value = new State; return *value; }
// Supported September 8 build, checked after runtime image validation.
// Supported game build, checked after runtime image validation.
bool validate_contract(std::uintptr_t base) {
struct Contract { std::uintptr_t rva; std::span<const unsigned char> bytes; };
const Contract contracts[]{
@@ -58,7 +58,8 @@ bool validate_contract(std::uintptr_t base) {
for (const auto& contract : contracts) {
std::array<unsigned char, 32> actual{};
SIZE_T count{};
if (!ReadProcessMemory(GetCurrentProcess(), reinterpret_cast<const void*>(base + contract.rva),
if (contract.bytes.size() > actual.size() || // a longer prefix would overrun actual
!ReadProcessMemory(GetCurrentProcess(), reinterpret_cast<const void*>(base + contract.rva),
actual.data(), contract.bytes.size(), &count) || count != contract.bytes.size() ||
std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size())) return false;
}
+386
View File
@@ -0,0 +1,386 @@
#include "map_download.h"
#include "live_mods.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Extension/UI/Overlay/overlay.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
namespace dingosdk::map_download {
namespace {
namespace fs = std::filesystem;
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.
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
std::mutex mutex;
std::atomic<Stage> stage{Stage::idle};
multiplayer::MapNeed need; // under mutex, while not idle
package::Name wanted; // under mutex
std::string version; // under mutex: the one to download
std::string newest; // under mutex
std::string description; // under mutex: Thunderstore's, or the installed mod's own
bool installed_off{}; // under mutex: here already, switched off
std::string last_failed; // under mutex: a package not to offer again this run
https::Watch watch;
std::atomic<bool> stop_install{};
std::atomic<bool> apply_wanted{}, apply_started{};
std::atomic<std::uint64_t> join_at{};
std::atomic<std::uint32_t> yes_bind{}, no_bind{};
std::atomic<bool> pick_download{true};
bool same(std::string_view a, std::string_view b) { return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0; }
fs::path mods_root() { return mods::engine_data_root() / mods::mods_folder; }
std::string read_bytes(const fs::path& path, std::uintmax_t most) {
std::error_code error;
const auto size = fs::file_size(path, error);
if (error || size > most) return {};
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>());
}
std::string host_word(bool server) { return server ? "server" : "host"; }
// 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", "", "");
}
// Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason) {
std::string text;
{
std::lock_guard lock(mutex);
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
", which is not installed on this PC. " + reason;
last_failed = need.package;
}
stage.store(Stage::idle);
leave_session();
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text);
overlay::notify(overlay::NoticeLevel::warning, "Map not installed", std::move(text));
}
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it
// says of itself.
std::optional<bool> installed(const std::string& folder, std::string& about) {
const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries)
if (same(entry.mod.name, folder)) {
about = entry.mod.description;
return entry.enabled;
}
return std::nullopt;
}
// Stage::checking: is it here but off, or what does Thunderstore say of it.
void check() {
package::Name name;
{
std::lock_guard lock(mutex);
name = wanted;
}
try {
std::string about;
if (const auto on = installed(name.folder(), about)) {
if (*on) return fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher.");
{
std::lock_guard lock(mutex);
installed_off = true;
description = about.substr(0, 240);
}
// Its own icon, from its folder.
try {
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);
return;
}
https::Download result;
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result);
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.");
const auto choice = package::choose(*answer, name, community);
if (!choice.ok) return fail(choice.reason + " Install its map mod yourself and join again.");
{
std::lock_guard lock(mutex);
version = choice.version;
newest = choice.newest;
description = choice.description;
}
// 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))
overlay::set_map_download_icon(*png);
stage.store(Stage::asking);
} catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what());
}
}
// Stage::downloading and Stage::installing, then Stage::applying is the client thread's.
void fetch() {
package::Name name;
std::string get, fallback;
bool off{};
{
std::lock_guard lock(mutex);
name = wanted;
get = version;
fallback = newest;
off = installed_off;
}
const auto root = mods_root();
const auto folder = name.folder();
try {
if (!off) {
wchar_t temp[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, temp);
const auto archive = fs::path(length ? std::wstring(temp, length) : L".") /
(L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(folder.begin(), folder.end()) + L".zip");
std::error_code error;
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 host's version can be gone from Thunderstore: the newest is the same map.
if (!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);
}
if (!result.ok) {
fs::remove(archive, error);
if (watch.cancel.load()) { stage.store(Stage::idle); leave_session(); return; }
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);
try {
launcher_mods::install(root, archive, false, [](float) {}, stop_install, {folder, name.owner, true});
} catch (const std::exception& failure) {
fs::remove(archive, error);
return fail(std::string("It could not be installed: ") + failure.what());
}
fs::remove(archive, error);
} else {
stage.store(Stage::installing);
}
// 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)) {
if (!off) {
try { launcher_mods::remove(root, folder); } catch (const std::exception&) {}
}
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) {
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);
}
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);
} catch (const std::exception& failure) {
fail(std::string("It could not be installed: ") + failure.what());
}
}
// 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(); }
} // namespace
void offer(const multiplayer::MapNeed& asked) {
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.)
if (!stage.compare_exchange_strong(idle, Stage::checking)) {
// 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;
}
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.");
leave_session();
}
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.)
overlay::notify(overlay::NoticeLevel::warning, "Map not installed",
"The " + host_word(asked.server) + (asked.moved ? " changed to " : " is on ") + asked.map +
", which is not installed on this PC. Install its map mod and join again.");
stage.store(Stage::idle);
leave_session();
return;
}
wanted = *name;
version.clear();
newest.clear();
description.clear();
installed_off = false;
}
overlay::set_map_download_icon({});
watch.cancel.store(false);
stop_install.store(false);
pick_download.store(true);
start(check);
}
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);
}
} catch (...) {
stage.store(Stage::idle);
}
}
bool asking() noexcept { return stage.load(std::memory_order_relaxed) == Stage::asking; }
void pick(bool download) noexcept { pick_download.store(download, std::memory_order_relaxed); }
bool picked() noexcept { return pick_download.load(std::memory_order_relaxed); }
void set_binds(std::uint32_t yes, std::uint32_t no) noexcept {
yes_bind.store(yes, std::memory_order_relaxed);
no_bind.store(no, std::memory_order_relaxed);
}
View view() {
View out;
out.stage = stage.load();
if (out.stage == Stage::idle || out.stage == Stage::checking) return out;
std::lock_guard lock(mutex);
out.map = need.map;
out.package = wanted.folder() + (version.empty() ? std::string() : " " + version);
out.author = wanted.owner;
out.version = version;
out.description = description;
if (out.stage == Stage::applying) {
const auto now = live_mods::progress();
out.step = now.done;
out.steps = now.total;
out.step_name = now.step;
}
out.server = need.server;
out.moved = need.moved;
out.installed = installed_off;
out.choice = pick_download.load(std::memory_order_relaxed);
out.received = watch.received.load();
out.total = watch.total.load();
out.yes_bind = yes_bind.load(std::memory_order_relaxed);
out.no_bind = no_bind.load(std::memory_order_relaxed);
return out;
}
void tick() {
const auto now = stage.load();
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.)
if (live_mods::busy()) return;
apply_wanted.store(false);
const auto said = live_mods::apply(false);
if (!live_mods::busy()) return fail("It is installed, but could not be applied now: " + said + " Restart the game to load it.");
apply_started.store(true);
return;
}
if (!apply_started.load() || live_mods::busy()) return;
apply_started.store(false);
std::string folder;
{
std::lock_guard lock(mutex);
folder = wanted.folder();
}
const auto applied = live_mods::applied_mods();
if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); }))
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);
return;
}
if (now == Stage::joining) {
const auto waited = GetTickCount64() - join_at.load();
if (waited < 2500) 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.");
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;
}
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.");
}
}
std::string package_of(std::string_view asset) {
struct Known {
std::string package;
std::uint64_t until{};
};
static std::mutex cache_mutex;
static std::map<std::string, Known, std::less<>> cache;
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;
}
cache[key] = {result, now + 120000};
return result;
}
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "Extension/Multiplayer/Session/session.h"
#include <cstdint>
#include <string>
#include <string_view>
// A map a multiplayer session needs and this PC does not have, fetched from Thunderstore
// while the game runs. The host says which package its map is from (session.h: MapNeed). The
// 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.
//
// 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
// map and nothing else (level bundles and their registration: no cosmetics, no settings, no
// scripts), or it is taken out again. Everything is downloaded from thunderstore.io, whatever
// a host says.
namespace dingosdk::map_download {
enum class Stage {
idle,
checking, // asking Thunderstore about the package; nothing is shown yet
asking, // the card asks: download, or not
downloading,
installing,
applying, // the live merge
joining, // the map is in the game: the session is loading it (or is joined again)
};
struct View {
Stage stage{};
std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0"
std::string author, version, description; // "Sandos", "2.0.0", and what its Thunderstore page says of it
// applying: the live merge's steps done of all, and the one in hand.
std::size_t step{}, steps{};
std::string step_name;
bool server{}; // a dedicated server's map, else a player's lobby
bool moved{}; // the session changed to it with the player in it (else: joining it)
bool installed{}; // asking: the mod is here but switched off, so nothing is downloaded
bool choice{true}; // asking: the answer a controller has picked (true: download)
std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known
std::uint32_t yes_bind{}, no_bind{}; // the vote binds, which answer the card
};
// Client thread: a session just ended over this map. Ignored while another is in hand.
void offer(const multiplayer::MapNeed& need);
// Any thread. Yes downloads (when asking); no declines, or stops a download under way.
void answer(bool download) noexcept;
bool asking() noexcept;
// A controller's pick between the two answers, and what it is.
void pick(bool download) noexcept;
bool picked() noexcept;
void set_binds(std::uint32_t yes, std::uint32_t no) noexcept;
View view();
// Client thread, every tick: starts the live apply and the join when their turn comes.
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);
}
+3 -2
View File
@@ -127,7 +127,7 @@ bool read_memory(std::uintptr_t address, void* destination, std::size_t size) no
destination, size, &read) && read == size;
}
// Supported September 8 build, checked after runtime image validation.
// Supported game build, checked after runtime image validation.
bool validate_contract(std::uintptr_t base) noexcept {
struct Contract { std::uintptr_t rva; std::span<const std::uint8_t> bytes; };
const Contract contracts[]{
@@ -135,7 +135,8 @@ bool validate_contract(std::uintptr_t base) noexcept {
{layers::layout_bootstrap, layers::layout_bootstrap_prefix}};
for (const auto& contract : contracts) {
std::array<std::uint8_t, 32> actual{};
if (!read_memory(base + contract.rva, actual.data(), contract.bytes.size()) ||
if (contract.bytes.size() > actual.size() || // a longer prefix would overrun actual
!read_memory(base + contract.rva, actual.data(), contract.bytes.size()) ||
std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size())) return false;
}
std::uintptr_t slot{};
@@ -0,0 +1,155 @@
// 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 <cmath>
#include <initializer_list>
#include <iostream>
#include <stdexcept>
using namespace dingosdk::board_break;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
bool near(float a, float b, float within = 1e-4f) { return std::abs(a - b) < within; }
bool near(Vec a, Vec b, float within = 1e-4f) { return length(a - b) < within; }
// Where a point of a part ends up after the part's motion.
Vec moved(const Rigid& motion, Vec point) { return turn(motion.facing, point) + motion.at; }
constexpr Quat level{};
// The rig's mount: a quarter turn about the upright axis.
const Quat mount = about({0, 1, 0}, 1.5707963f);
void a_break_leaves_two_pieces_or_one_that_hangs_together() {
for (const auto place : {Place::nose, Place::mid, Place::tail}) {
const auto clean = broken_at(place, false);
check(clean.hinge == Hinge::none && clean.loose, "A clean break has a piece that comes off");
check((clean.kept | clean.loose) == whole_board && !(clean.kept & clean.loose), "and the two are the whole board, each part once");
const auto joined = broken_at(place, true);
check(joined.kept == whole_board && !joined.loose && joined.hinge != Hinge::none, "Held by the grip tape, nothing comes off");
}
check(broken_at(Place::nose, false).loose == nose && broken_at(Place::tail, false).loose == tail, "An end comes off by itself");
const auto halves = broken_at(Place::mid, false);
check(halves.kept == (nose | front | front_truck) && halves.loose == (back | tail | back_truck),
"In the middle the board keeps its front half, and each half its truck");
check(broken_at(Place::nose, true).hinge == Hinge::nose && broken_at(Place::mid, true).hinge == Hinge::fold &&
broken_at(Place::tail, true).hinge == Hinge::tail,
"and each place hangs together its own way");
}
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");
float before{};
for (float force = 0; force <= 12.0f; force += 0.5f) {
const auto chance = break_chance(force, 10.0f, 0.25f);
check(chance >= before && chance <= 1.0f, "It never falls with more force");
before = chance;
}
}
void the_size_of_a_break_picks_its_sound() {
check(break_size(Place::mid, false, 0.0f) == Size::heavy, "A board that comes apart in the middle is the biggest break");
check(break_size(Place::mid, true, 0.9f) == Size::heavy && break_size(Place::mid, true, 0.4f) == Size::medium,
"A fold right down is as big, a shallow one in between");
check(break_size(Place::nose, false, 0.0f) == Size::medium && break_size(Place::tail, false, 0.0f) == Size::medium, "An end that comes off is in between");
check(break_size(Place::nose, true, 0.0f) == Size::light && break_size(Place::tail, true, 0.0f) == Size::light, "One that hangs on is a small one");
}
void a_whole_board_keeps_its_stretches_away() {
const auto bones = bones_of({});
for (std::size_t i = 0; i < 3; ++i) check(near(bones[i].at, away), "A stretch that does not show goes far away");
check(near(bones[3].at, Vec{}) && near(bones[3].facing.w, 1.0f), "and the tail's, which hangs on the front's, goes with that one");
}
void the_tail_is_placed_from_the_front_stretch() {
// The tail's bone hangs on the front stretch's: wherever the two lie, the front's motion
// followed by what goes into the tail's bone is the tail's own motion.
const Rigid front_motion{about({1, 0, 0}, 0.3f), {0.01f, 0.02f, -0.03f}};
const Rigid tail_motion{unit(about({0, 1, 0}, 1.1f) * about({1, 0, 0}, -0.4f)), {0.5f, -0.2f, 0.7f}};
for (const bool front_shown : {true, false}) {
std::array<Stretch, 4> stretch{};
stretch[1] = {front_shown, front_motion};
stretch[3] = {true, tail_motion};
const auto bones = bones_of(stretch);
const Rigid parent = front_shown ? front_motion : Rigid{{}, away};
for (const Vec point : {Vec{0, 0.09f, -0.3f}, Vec{0.1f, 0.1f, -0.4f}})
check(near(moved(parent, moved(bones[3], point)), moved(tail_motion, point), 2e-3f), "The tail lies where its piece does");
}
}
void a_piece_shows_only_its_own_stretches() {
const auto clean = broken_at(Place::mid, false);
const auto layout = lay_out(clean.kept, clean.hinge, 0.0f, 1.0f, mount);
check(layout.stretch[0].shown && layout.stretch[1].shown && !layout.stretch[2].shown && !layout.stretch[3].shown,
"The front half is the nose and the front stretch");
check(layout.front_truck && !layout.back_truck, "on its front truck");
const auto bones = bones_of(layout.stretch);
check(near(bones[2].at, away), "and the back stretch is nowhere to be seen");
// Half a board on one truck tips onto its broken end while it is the right way up, and not on its grip.
const auto broken_end = moved(layout.stretch[1].motion, turn(mount, Vec{0, 0.09f, mid_row}));
check(broken_end.y < 0.09f - 0.03f, "The half tips onto its broken end");
const auto upside_down = lay_out(clean.kept, clean.hinge, 0.0f, -1.0f, mount);
check(near(upside_down.stretch[1].motion.at, Vec{}), "Lying on its grip it stays as it is");
}
void a_folded_board_meets_at_the_break() {
for (const float depth : {0.2f, 0.6f, 1.0f}) {
Rigid front_half, back_half;
folded(depth, front_half, back_half);
// The grip of both halves at the break: the same height, a crack's width apart.
const Vec grip{0, 0.095f, mid_row};
const auto ahead = moved(front_half, grip), behind = moved(back_half, grip);
check(near(ahead.y, behind.y, 1e-3f), "Both halves' grip is at one height at the break");
check(near(ahead.z - behind.z, crack, 1e-3f), "with the crack between them");
check(ahead.y < grip.y, "and the break has sagged");
// Each half turns about its own axle: the wheels stay where they were, but for the slide along the board.
check(near(moved(front_half, {0, axle_height, front_axle}).y, axle_height, 1e-4f) &&
near(moved(back_half, {0, axle_height, back_axle}).y, axle_height, 1e-4f),
"The axles keep their height");
}
const auto whole = lay_out(whole_board, Hinge::fold, 0.8f, 1.0f, mount);
for (const auto& one : whole.stretch) check(one.shown, "A folded board shows all four stretches");
check(!near(whole.stretch[1].motion.at, whole.stretch[2].motion.at), "its two halves moved apart");
check(near(whole.stretch[0].motion.at, whole.stretch[1].motion.at) && near(whole.stretch[2].motion.at, whole.stretch[3].motion.at),
"and each end with its half");
}
void a_hanging_end_turns_about_the_break() {
const auto layout = lay_out(whole_board, Hinge::nose, 0.5f, 1.0f, mount);
check(near(layout.stretch[1].motion.at, Vec{}) && near(layout.stretch[2].motion.at, Vec{}) && near(layout.stretch[3].motion.at, Vec{}),
"The rest of the board stays where it is");
const auto tip = moved(layout.stretch[0].motion, turn(mount, Vec{0, 0.14f, 0.41f}));
check(tip.y < 0.14f - 0.05f, "and the nose droops");
const auto hinge = moved(hung_on_grip(nose_row, 1.0f, 0.5f), {0, grip_height - 0.004f, nose_row});
check(near(hinge, Vec{0, grip_height - 0.004f, nose_row + crack * 0.5f}), "It turns about the break line, a hair away from the board");
}
void a_truck_goes_with_its_piece() {
// The deck's bone is moved away to hide the real deck, and the truck hangs on it: what goes into
// the truck's bone takes that move back and adds the piece's motion.
const Vec mount_at{0, 0.0856f, 0};
const auto still = truck_bone({}, mount, mount_at, level, front_truck_place);
check(near(still.at + away, front_truck_place) && near(still.facing.w, 1.0f), "A piece that has not moved leaves its truck where it was");
const Rigid lifted{{}, {0, 0.25f, 0}};
const auto up = truck_bone(lifted, mount, mount_at, level, front_truck_place);
check(near(up.at + away, front_truck_place + Vec{0, 0.25f, 0}), "A piece that is lifted takes its truck up with it");
}
} // namespace
int main() {
try {
a_break_leaves_two_pieces_or_one_that_hangs_together();
a_harder_slam_breaks_a_board_more_often();
the_size_of_a_break_picks_its_sound();
a_whole_board_keeps_its_stretches_away();
the_tail_is_placed_from_the_front_stretch();
a_piece_shows_only_its_own_stretches();
a_folded_board_meets_at_the_break();
a_hanging_end_turns_about_the_break();
a_truck_goes_with_its_piece();
std::cout << "board break tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "board break test failed: " << error.what() << '\n';
return 1;
}
}
+670
View File
@@ -0,0 +1,670 @@
#include "board_break.h"
#include "board_break_model.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Build/20260929/no_bail.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Skater/no_bail.h"
#include <Windows.h>
#include <mmsystem.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cwctype>
#include <filesystem>
#include <format>
#include <limits>
#include <mutex>
#include <optional>
#include <random>
#include <string>
#include <vector>
#pragma comment(lib, "winmm.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;
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.
constexpr float hand_reach = 1.3f, hand_upright = 0.8f;
// The board rig's pose, in the order the game evaluates it: 1 the rig's place in the world, 2 the
// deck's mount, 3 the deck, 4 and 8 the trucks, and the spare bones that carry the four stretches
// (nose, front, back, tail; the tail's hangs on the front's).
constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, front_truck_bone = 8;
constexpr std::array<int, 4> carrier{12, 13, 15, 14};
constexpr std::uint32_t rig_bones = 16;
bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; }
template <class... Args> void say(logging::Level level, std::format_string<Args...> text, Args&&... args) {
logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward<Args>(args)...));
}
// Written by the physics step, read by the client tick.
struct Hit {
std::atomic<std::uint64_t> at{};
std::atomic<std::int32_t> reason{-1};
std::atomic<float> force{};
};
Hit& last_cause() { static Hit value; return value; }
Hit& last_wipeout() { static Hit value; return value; }
struct Broken {
std::uintptr_t board{};
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
};
struct State {
// Any thread.
std::atomic<bool> ready{}, on{true}, wanted_mend{};
std::atomic<std::uintptr_t> base{};
std::atomic<bool> has_deck{}; // one of Board Break's decks is the one in the game
std::atomic<int> wanted_snap{-1}; // asked for from another thread, done at the next tick
std::mutex names;
std::string deck, wanted_deck; // under `names`: the deck in the game, and one asked for (done at the next tick)
// Client thread from here on.
std::optional<Broken> broken;
Break parts; // what the broken board keeps, what flew off, how they hang together
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{};
// 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{}()};
};
State& state() {
static auto* value = new State;
return *value;
}
// What the render pose hook does to the boards, published by the client tick.
struct RenderEdit {
std::atomic<std::uintptr_t> holder{}; // the local board's animation holder
std::atomic<std::uint32_t> kept{}; // the piece it shows (Piece bits), 0 while it is whole
std::atomic<int> hinge{};
std::atomic<float> angle{};
std::atomic<bool> park{}; // a breakable deck is in the game: whole boards hide their stretches
};
RenderEdit& render_edit() { static RenderEdit value; return value; }
// ---- the board's pose --------------------------------------------------------------------------
// One bone: scale, rotation and position, four floats each.
struct PoseBone {
float scale[4], rotation[4], position[4];
};
bool load_bone(std::uintptr_t address, PoseBone& out) noexcept {
__try {
std::memcpy(&out, reinterpret_cast<const void*>(address), sizeof(out));
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
}
// Rotation and position of one bone, at its own size; the fourth lane of each is left as it is.
bool store_bone(std::uintptr_t address, Quat rotation, Vec position) noexcept {
__try {
auto* lane = reinterpret_cast<float*>(address);
lane[0] = lane[1] = lane[2] = 1.0f;
lane[4] = rotation.x; lane[5] = rotation.y; lane[6] = rotation.z; lane[7] = rotation.w;
lane[8] = position.x; lane[9] = position.y; lane[10] = position.z;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
}
Quat facing_of(const PoseBone& bone) noexcept { return unit({bone.rotation[0], bone.rotation[1], bone.rotation[2], bone.rotation[3]}); }
Vec place_of(const PoseBone& bone) noexcept { return {bone.position[0], bone.position[1], bone.position[2]}; }
// The pose buffer of a board, or 0 when it is not the rig this knows.
std::uintptr_t board_pose(std::uintptr_t base, std::uintptr_t holder) {
const auto pose = multiplayer::read_native_pose_layout(readable, base, holder, 64);
return pose.buffer && pose.count == rig_bones ? pose.buffer : 0;
}
std::uintptr_t bone_at(std::uintptr_t buffer, int index) noexcept { return buffer + static_cast<std::uint32_t>(index) * 0x30ULL; }
// The board's place in the world as its pose has it.
bool board_place(std::uintptr_t base, std::uintptr_t holder, Vec& at, Quat& facing) {
const auto buffer = board_pose(base, holder);
PoseBone root{};
if (!buffer || !load_bone(bone_at(buffer, root_bone), root)) return false;
at = place_of(root);
facing = facing_of(root);
return std::isfinite(at.x) && std::isfinite(at.y) && std::isfinite(at.z);
}
// Moves a truck with a piece (truck_bone).
void carry_truck(std::uintptr_t buffer, const Rigid& motion, int index, Vec place) noexcept {
PoseBone mount{}, truck{};
if (!load_bone(bone_at(buffer, mount_bone), mount) || !load_bone(bone_at(buffer, index), truck)) return;
const auto bone = truck_bone(motion, facing_of(mount), place_of(mount), facing_of(truck), place);
(void)store_bone(bone_at(buffer, index), bone.facing, bone.at);
}
// ---- 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
// wherever the board itself is in that frame, so the two part company although both are the one board.
struct Flight {
std::mutex mutex;
// From the tick.
std::uint32_t id{}; // changes with every break; 0 = no piece in flight
std::uint32_t mask{}; // the piece's stretches of deck and its trucks
float ground{}; // world height of the ground under the skater
float rest{}; // how far above the ground the piece's origin lies once it rests
Vec kick{}, spin{}; // start speed (m/s) and turning (axis x rad/s), in the deck's own axes
float ridden{std::numeric_limits<float>::quiet_NaN()}; // the ground the board rolled on as it broke
// The hook's.
std::uint32_t running{};
std::int64_t last{};
Vec at{}, speed{}, turning{};
Quat facing{};
bool landed{}; // lying on what is taken for the ground right now
bool settled{}; // has landed once: flat from then on, whichever side came up
bool upside_down{};
Vec from{}; // where the board was when it broke
float limit{}; // the tick's first guess of the ground: never taken higher than this
float tipped{}; // 0..1: how far a half on one truck has tipped onto its broken end
};
Flight& flight() { static Flight value; return value; }
// The stretches of deck that are in flight (0 for none) and, in `piece`, where they are from the
// board's own place; the piece's trucks are moved here.
std::uint32_t fly_piece(std::uintptr_t buffer, Rigid& piece) {
auto& f = flight();
std::unique_lock lock(f.mutex, std::try_to_lock);
if (!lock.owns_lock() || !f.id) return 0;
PoseBone root{}, mount{};
if (!load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return 0;
const Vec board_at = place_of(root);
const Quat board_facing = facing_of(root), deck_turn = facing_of(mount);
LARGE_INTEGER now{};
QueryPerformanceCounter(&now);
static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast<double>(value.QuadPart); }();
if (f.running != f.id) {
f.running = f.id;
f.last = now.QuadPart;
f.at = f.from = board_at;
f.facing = board_facing;
f.speed = turn(board_facing, turn(deck_turn, f.kick));
f.turning = turn(board_facing, turn(deck_turn, f.spin));
f.landed = f.settled = f.upside_down = false;
f.limit = f.ground;
f.tipped = 0;
}
const auto dt = static_cast<float>(std::clamp((now.QuadPart - f.last) / frequency, 0.0, 0.05));
f.last = now.QuadPart;
// The ground is where the board itself lies: its lowest point right now, which physics brings
// to rest on the ground. The skater's height at the slam (the hips, and the skater may be in the
// air) is only an upper limit. It is the board's height NOW, not the lowest ever seen: one odd
// pose (a teleport, a frame before the board is placed) must not sink the piece for good, and a
// pose that is nowhere near the break is passed over altogether.
const bool plausible = std::isfinite(board_at.x) && std::isfinite(board_at.y) && std::isfinite(board_at.z) && length(board_at - f.from) < 30.0f;
float lowest = board_at.y + 1.0f;
if (plausible) {
for (const Vec corner : {Vec{0, 0, 0}, Vec{0, 0.093f, 0}, Vec{0, 0.141f, 0.41f}, Vec{0, 0.136f, -0.40f}, Vec{0.105f, 0.10f, 0},
Vec{-0.105f, 0.10f, 0}, Vec{0.0876f, 0, front_axle}, Vec{-0.0876f, 0, front_axle}, Vec{0.0876f, 0, back_axle},
Vec{-0.0876f, 0, back_axle}})
lowest = (std::min)(lowest, (board_at + turn(board_facing, turn(deck_turn, corner))).y);
// Only a board that is still near the piece says anything about the ground under the piece.
if (std::hypot(board_at.x - f.at.x, board_at.z - f.at.z) < 4.0f) {
// The ground the board rolled on as it broke is exact, where its lowest point depends on
// how it has come to lie and on what: that height stays, unless the board has plainly
// dropped below it since (off a ledge, down a stair).
if (std::isfinite(f.ridden)) f.ground = lowest < f.ridden - 0.15f ? lowest : f.ridden;
else f.ground = (std::min)(f.limit, lowest);
}
}
// Where the piece's origin is once it lies on the ground: on its grip when it is upside down,
// else on its underside or its wheels. A little above it: real ground is rough, and a deck is a
// centimetre thick. A gap under a piece goes unseen, a piece cut off by the ground does not.
const float lift = f.mask & (front_truck | back_truck) ? 0.004f : 0.012f;
const auto lying = [&] {
const bool flipped = f.settled ? f.upside_down : turn(f.facing, {0, 1, 0}).y < 0;
return (flipped ? f.ground + 0.141f : f.ground + f.rest) + lift; // upside down it rests on the raised end of its grip side
};
if (f.landed && f.at.y > lying() + 0.01f) {
f.landed = false; // the ground turned out to be lower: it drops the rest of the way
f.speed = {};
f.turning = {};
}
if (!f.landed) {
f.speed.y -= 9.81f * dt;
f.at = f.at + f.speed * dt;
if (const auto rate = length(f.turning); rate > 1e-4f) f.facing = unit(about(f.turning * (1.0f / rate), rate * dt) * f.facing);
if (f.speed.y <= 0 && f.at.y <= lying()) { // on its way down: it starts on the ground it will land on
if (!f.settled) {
// Down: lying flat, pointing the way it was, the same side up.
const auto ahead = turn(f.facing, {0, 0, 1});
f.upside_down = turn(f.facing, {0, 1, 0}).y < 0;
f.facing = about({0, 1, 0}, std::atan2(ahead.x, ahead.z));
if (f.upside_down) f.facing = unit(f.facing * about(turn(deck_turn, {0, 0, 1}), 3.14159265f)); // rolled over, about its length
f.speed = {f.speed.x * 0.3f, 0, f.speed.z * 0.3f};
f.settled = true;
} else f.speed = {};
f.at.y = lying();
f.landed = true;
}
} else {
f.at = f.at + f.speed * dt; // a short slide
f.speed = f.speed * (std::max)(0.0f, 1.0f - 7.0f * dt);
f.at.y = lying(); // and it stays on the ground if that comes up again
}
// A half board that came down on its wheels tips onto its broken end.
const bool on_one_truck = (f.mask & (back_truck | front_truck)) && f.settled && !f.upside_down;
f.tipped = on_one_truck && f.landed ? (std::min)(1.0f, f.tipped + dt / 0.18f) : on_one_truck ? f.tipped : 0.0f;
const auto tip = on_root(deck_turn, f.mask & back_truck ? tipped_on_axle(back_axle, 1.0f, back_tip * f.tipped)
: tipped_on_axle(front_axle, -1.0f, front_tip * f.tipped));
// Where that is from the board's own place.
const auto back_to_board = conj(board_facing);
const auto lies_facing = unit(back_to_board * f.facing);
const auto lies_at = turn(back_to_board, f.at - board_at);
piece = {unit(lies_facing * tip.facing), lies_at + turn(lies_facing, tip.at)};
if (f.mask & back_truck) carry_truck(buffer, piece, back_truck_bone, back_truck_place);
if (f.mask & front_truck) carry_truck(buffer, piece, front_truck_bone, front_truck_place);
return f.mask & stretches;
}
// ---- what the hook writes ------------------------------------------------------------------------
// The local board, broken: the real deck goes away, the trucks come back, each stretch goes to
// where its piece lies and one that belongs to no piece goes away too.
void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, Hinge hinge, float angle) {
const auto buffer = board_pose(base, holder);
PoseBone root{}, mount{}, deck{};
if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount) ||
!load_bone(bone_at(buffer, deck_bone), deck))
return;
// 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));
if (layout.back_truck) carry_truck(buffer, layout.back_motion, back_truck_bone, back_truck_place);
if (layout.front_truck) carry_truck(buffer, layout.front_motion, front_truck_bone, front_truck_place);
if (hinge == Hinge::none) {
Rigid piece{};
const auto flying = fly_piece(buffer, piece);
for (std::size_t i = 0; i < layout.stretch.size(); ++i)
if (flying & (1u << i)) layout.stretch[i] = {true, piece};
}
const auto bones = bones_of(layout.stretch);
for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at);
}
// A whole board, any player's: its four stretches go far away from it.
void park_pieces(std::uintptr_t base, std::uintptr_t holder) {
const auto buffer = board_pose(base, holder);
if (!buffer) return;
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""};
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());
}
break;
}
return found;
}();
// 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;
}
}
// `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 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.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)],
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);
s.broken.reset();
s.parts = {};
}
// 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 {
bool known{};
float distance{}, upright{};
bool in_hand() const noexcept { return known && distance < hand_reach && upright < hand_upright; }
};
Hold board_hold(std::uintptr_t base, std::uintptr_t client) {
Hold out;
const auto frame = multiplayer::capture_local(base, client, true);
if (!frame.ready || frame.pose.board.empty()) return out;
const auto& skater = frame.pose.root.position;
const auto& board = frame.pose.board.front();
out.distance = std::hypot(board.position[0] - skater[0], board.position[1] - skater[1], board.position[2] - skater[2]);
const auto& q = board.rotation; // x, y, z, w: the board's up axis, turned into the world
out.upright = std::abs(1.0f - 2.0f * (q[0] * q[0] + q[2] * q[2]));
out.known = std::isfinite(out.distance) && std::isfinite(out.upright);
return out;
}
bool riding(std::uint32_t physics) noexcept {
// Ground/air and grind families; 300 is the wipeout, 5xx is on foot and getting on.
return (physics >= 100 && physics < 300) || (physics >= 400 && physics < 500);
}
// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next"
// for the one after the deck in the game. Empty when there is no such deck.
std::string deck_meant(std::string wanted, const std::string& in_game, const std::vector<std::string>& installed) {
for (auto& c : wanted) c = c == ' ' ? '_' : static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (installed.empty()) return {};
if (wanted.empty() || wanted == "next") {
const auto now = std::find(installed.begin(), installed.end(), in_game);
return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1);
}
if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) {
// The decks the feature was made with keep their numbers whether they are installed or not;
// the others follow, in the order they are listed.
const auto number = static_cast<std::size_t>(std::stoi(wanted));
const auto& known = mods::board_break_decks;
if (number >= 1 && number <= known.size()) return std::string(known[number - 1]);
std::size_t next = known.size();
for (const auto& deck : installed)
if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck;
return {};
}
return wanted;
}
// What the hook is told this tick; `holder` 0 leaves the local board to be treated as any other.
void publish(State& s, std::uintptr_t base, std::uintptr_t client, std::uintptr_t holder) {
auto& edit = render_edit();
const bool broken = s.broken && holder;
edit.park.store(s.has_deck.load(std::memory_order_acquire), std::memory_order_release);
edit.angle.store(s.angle, std::memory_order_release);
edit.hinge.store(static_cast<int>(broken ? s.parts.hinge : Hinge::none), std::memory_order_release);
edit.kept.store(broken ? s.parts.kept : 0, std::memory_order_release);
edit.holder.store(holder, std::memory_order_release);
// The broken-off piece: one flight per break, gone when the board is whole again.
auto& f = flight();
std::lock_guard lock(f.mutex);
if (!broken || !s.parts.loose) f.id = 0;
else if (f.id != s.flights) {
const auto frame = multiplayer::capture_local(base, client, true);
const auto roll = [&](float low, float high) { return std::uniform_real_distribution<float>(low, high)(s.random); };
const bool loose_end = !(s.parts.loose & (front_truck | back_truck)); // a nose or tail, no truck on it
const float along = s.parts.loose & nose ? 1.0f : -1.0f; // the way it leaves, along the board
const float push = loose_end ? 1.0f : 0.55f;
f.mask = s.parts.loose;
f.ground = frame.ready ? frame.pose.root.position[1] : 0.0f;
f.rest = loose_end ? -0.083f : 0.0f; // a bare piece lies on its underside, one with a truck stands on its wheels
f.ridden = s.break_ground;
f.kick = {roll(-0.9f, 0.9f) * push, (1.4f + roll(0.0f, 1.2f)) * push, along * (1.2f + roll(0.0f, 1.6f)) * push};
f.spin = {along * (5.0f + roll(0.0f, 6.0f)) * push, roll(-3.0f, 3.0f) * push, roll(-2.0f, 2.0f) * push};
f.id = s.flights;
}
}
} // namespace
bool start(std::uintptr_t base) noexcept {
auto& s = state();
if (s.ready.load(std::memory_order_acquire)) return true;
if (!no_bail_available()) {
logging::write(logging::Level::warning, logging::Channel::skater,
"Board Break cannot follow the skater: its slams come through No Bail's hooks, which did not start.");
return false;
}
s.base.store(base, std::memory_order_release);
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release);
const auto in_game = mods::board_break_in_play();
{
std::lock_guard lock(s.names);
s.deck = in_game;
}
s.has_deck.store(!in_game.empty(), std::memory_order_release);
s.ready.store(true, std::memory_order_release);
logging::log(logging::Level::info, logging::Channel::skater, "Board Break ready ({}, deck: {}).", s.on.load() ? "on" : "off",
deck_label(in_game));
return true;
}
void note_cause(std::int32_t reason, float magnitude) noexcept {
auto& hit = last_cause();
const auto now = GetTickCount64();
// Keep the strongest of a burst: one slam records several causes.
if (now - hit.at.load(std::memory_order_relaxed) < 250 && magnitude <= hit.force.load(std::memory_order_relaxed)) return;
hit.force.store(magnitude, std::memory_order_relaxed);
hit.reason.store(reason, std::memory_order_relaxed);
hit.at.store(now, std::memory_order_release);
}
void note_wipeout(std::uintptr_t causes) noexcept {
std::array<std::uint8_t, cause_count> set{};
std::array<float, cause_count> force{};
auto& hit = last_wipeout();
std::int32_t reason{-1};
float strongest{};
if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force)))
for (std::size_t i = 0; i < cause_count; ++i)
if (set[i] && (reason < 0 || force[i] > strongest)) {
reason = static_cast<std::int32_t>(i);
strongest = force[i];
}
hit.force.store(strongest, std::memory_order_relaxed);
hit.reason.store(reason, std::memory_order_relaxed);
hit.at.store(GetTickCount64(), std::memory_order_release);
}
void on_render_pose(std::uintptr_t animation_interface) noexcept {
auto& edit = render_edit();
// Without a breakable deck in the game the boards have no stretches: nothing to do.
if (!edit.park.load(std::memory_order_acquire) || animation_interface <= 0xc0) return;
const auto base = state().base.load(std::memory_order_acquire);
const auto holder = edit.holder.load(std::memory_order_acquire);
const auto kept = edit.kept.load(std::memory_order_acquire);
const auto error = GetLastError();
try {
if (kept && holder && animation_interface == holder + 0xc0)
draw_broken(base, holder, kept, static_cast<Hinge>(edit.hinge.load(std::memory_order_acquire)), edit.angle.load(std::memory_order_acquire));
else park_pieces(base, animation_interface - 0xc0);
} catch (...) { /* A board that is being built or torn down: the next frame. */ }
SetLastError(error);
}
void on_client_tick(std::uintptr_t client) noexcept {
auto& s = state();
if (!s.ready.load(std::memory_order_acquire)) return;
const auto base = s.base.load(std::memory_order_acquire);
try {
const auto now = GetTickCount64();
// The deck in the game: the launch's, or the one the last live apply of the mods put there.
const auto deck_in_game = mods::board_break_in_play();
const bool in_game = !deck_in_game.empty();
std::string wanted;
bool changed{};
{
std::lock_guard lock(s.names);
changed = s.deck != deck_in_game;
s.deck = deck_in_game;
wanted.swap(s.wanted_deck);
}
s.has_deck.store(in_game, std::memory_order_release);
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 (in_game && !s.hooked) {
// The boards' render pose hook is shared with multiplayer and first person, and only
// installed when one of them asks.
std::string detail;
s.hooked = multiplayer::install_entity_hooks(base, detail);
if (!s.hooked && now - s.last_error > 10000) {
s.last_error = now;
say(logging::Level::warning, "the boards' render pose hook is unavailable ({}).", detail);
}
}
NoBailSkater skater;
std::uintptr_t board{}, holder{};
if (in_game && no_bail_skater(skater)) {
const auto found = multiplayer::read_native_board(readable, base, skater.entity);
board = found.entity;
holder = found.holder;
}
if (!board) {
// No local skater or no board (a respawn, a level change): the next board is whole. A
// broken one that is only out of sight for a moment stays as it is.
if (s.broken && in_game && now - s.board_seen < 500) return;
mend(s, "the board is gone");
s.counted = false;
s.wanted_snap.store(-1, std::memory_order_release);
s.wanted_mend.store(false, std::memory_order_release);
publish(s, base, client, 0);
return;
}
s.board_seen = now;
if (s.broken && s.broken->board != board) mend(s, "the skater has another board");
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");
const bool carried = watch.valid && watch.state >= 500 && watch.state < 600; // on foot and getting on
if (const int place = s.wanted_snap.exchange(-1, std::memory_order_acq_rel); place >= 0)
(void)snap(s, board, place < 3 ? std::optional(static_cast<Place>(place)) : std::nullopt, false, carried, "asked for", true);
if (watch.valid) {
if (watch.state == 100) {
// Rolling on the ground, the right way up: the board's own height is the ground's.
Vec at{};
Quat facing{};
if (board_place(base, holder, at, facing) && turn(facing, {0, 1, 0}).y > 0.9f) {
s.ride_height = at.y;
s.ride_when = now;
}
}
const bool on = s.on.load(std::memory_order_acquire);
if (!s.counted) {
s.wipeouts = watch.wipeouts;
s.counted = true;
}
if (watch.wipeouts != s.wipeouts) {
s.wipeouts = watch.wipeouts;
// The cause that made the selector choose Wipeout, or else the last one recorded.
auto* hit = &last_wipeout();
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));
}
// 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
// broken board like any other.
const auto hold = board_hold(base, client);
const bool on_it = riding(watch.state);
if (s.broken->gave_way && watch.state == addr::no_bail::wipeout_physics_state) s.broken->gave_way = false;
const bool back = s.broken->gave_way ? !on_it && hold.in_hand()
: on_it || (watch.state != addr::no_bail::wipeout_physics_state && hold.in_hand());
// A respawn gives the skater a new physics selector, which the watch reports as state 0
// until it has chosen once: the board is whole from that moment.
if (watch.state == 0 && now - s.broken->since > 300) mend(s, "a respawn");
else if (s.broken->asked) s.back_since = 0;
else if (!back) s.back_since = 0;
else if (!s.back_since) s.back_since = now;
else if (now - s.back_since > 300 && now - s.broken->since > 1200) mend(s, on_it ? "riding again" : "back in hand");
}
}
publish(s, base, client, holder);
} catch (const std::exception& error) {
// The skater or its board is being rebuilt: tried again at a later tick.
const auto now = GetTickCount64();
if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) {
s.last_error = now;
say(logging::Level::debug, "waiting ({}).", error.what());
}
} catch (...) { /* A broken board is never worth the client tick. */ }
}
bool available() noexcept { return state().ready.load(std::memory_order_acquire); }
bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); }
void set_enabled(bool enabled) noexcept {
state().on.store(enabled, std::memory_order_release);
profile_runtime::set_local_preference(enabled_preference, enabled);
}
std::string deck() {
auto& s = state();
std::lock_guard lock(s.names);
return s.deck;
}
std::string deck_label(std::string_view deck) {
if (deck.empty()) return "None";
std::string name(deck);
bool start = true;
for (auto& c : name) {
if (c == '_') c = ' ';
else if (start) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
start = c == ' ';
}
return name;
}
std::string choose_deck(std::string wanted) {
auto& s = state();
if (!available()) return "Board Break is unavailable for this game build.";
const auto installed = mods::installed_board_break_decks();
if (installed.empty()) return "None of Board Break's decks is installed.";
const auto in_game = deck();
const auto chosen = deck_meant(wanted, in_game, installed);
if (chosen.empty()) return "There is no deck " + wanted + ".";
if (std::find(installed.begin(), installed.end(), chosen) == installed.end())
return "The " + deck_label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder.";
if (chosen == in_game) return "The " + deck_label(chosen) + " deck is the one in the game.";
{
std::lock_guard lock(s.names);
s.wanted_deck = chosen;
}
return "The " + deck_label(chosen) + " deck: the mods are applied and the level loads again with it.";
}
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); }
} // namespace dingosdk::board_break
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
// Board Break: a hard slam can break the local skater's board. It snaps at the nose, in the middle
// or at the tail; half the time the grip tape holds the pieces together and the board folds or an
// end hangs, otherwise a piece comes off, flies and lies where it lands. The board is whole again
// once the skater has picked it up, rides again or respawns. How hard the slam was (the force of
// the bail cause that led to the wipeout) decides whether the board breaks: a heavy one always, a
// lighter one now and then.
//
// The pieces are not drawn by ReSkate: they are in the deck model of a content mod, one mod for
// each deck shape, any folder named Board_Break_<Name> (Engine/Vfs/mod_catalog.h). The game has one
// deck model for every board; such a mod replaces it with the same deck plus four stretches of it
// (nose, front, back, tail) cut along the break rows, each on a spare bone of the board's rig
// (contrib/board-break.md says how such a model is made). This only moves bones,
// in the boards' render pose hook (Extension/Multiplayer/Remote/native_skater.h):
// - a broken board has its real deck moved far away, the trucks moved back to where they were,
// and each stretch moved to where its piece lies;
// - a whole board has its stretches moved far away, every frame, on every board in the game: the
// game draws a deck's wood and grip as layers that do not hide what lies just under them, so
// stretches left inside the deck show as cracks.
// Nothing is scaled: the game's replay records where a bone is and how it is turned, not its size,
// so a break made of moves plays back in the replay editor as it happened.
//
// Without such a mod installed and loading first nothing ever breaks. With one, mods.json is not
// changed: the deck the player chose goes first for the run, and the other decks and any mod that
// reshapes the deck are set aside (mod_catalog.cpp). Choosing another deck applies the mods afresh
// and loads the level again (Extension/Assets/live_mods.h); the game reads the model then.
namespace dingosdk::board_break {
// Requires the validated build, No Bail started and the local profile loaded: the switch starts
// from the saved choice (on until a player changes it).
bool start(std::uintptr_t image_base) noexcept;
// Client thread, every tick, with the local client.
void on_client_tick(std::uintptr_t client) noexcept;
// From No Bail's hooks, on the physics thread: a bail cause recorded for the watched skater, and
// the moment its selector chose Wipeout (`causes` is its cause collector, read before it resets).
void note_cause(std::int32_t reason, float magnitude) noexcept;
void note_wipeout(std::uintptr_t causes) noexcept;
// From the boards' render pose hook, after a board's pose has been handed to the renderer and
// after another player's pose has been written into it (`animation_interface` is the board's
// animation holder + 0xc0): what is written to the pose here is what this frame draws.
void on_render_pose(std::uintptr_t animation_interface) noexcept;
// Any thread.
bool available() noexcept;
bool enabled() noexcept; // off: boards hold; one that is broken stays so until it is picked up
void set_enabled(bool enabled) noexcept;
// The name of the deck in the game ("standard", "wide" and so on), empty when none of Board Break's
// decks is: boards cannot break then. And a deck's name as the menus show it ("Powell"; "None").
std::string deck();
std::string deck_label(std::string_view deck);
// Asks for another deck: its name, its number in the list `boardbreak deck` prints, or "next" for
// the one after the deck in the game. Says what happens, for the console; the mods are applied and
// the level loaded again from the next tick.
std::string choose_deck(std::string wanted);
// Done at the next tick: breaks the board at `place` (0 nose, 1 middle, 2 tail; anything else is
// left to chance), to be looked at: that break stays until the board is mended here or the skater
// respawns. Or makes a broken board whole.
void snap(int place) noexcept;
void mend() noexcept;
}
@@ -0,0 +1,75 @@
#include "board_break.h"
#include "board_break_model.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Extension/Console/commands.h"
#include <algorithm>
// Board Break's console switches; the menu's (Mod Options > Player) send them too.
namespace dingosdk::console {
void register_board_break_commands(Commands &registry) {
const auto unavailable = "Board Break is unavailable for this game build.";
auto on = variable("boardbreak", "A hard slam can snap your board at the nose, in the middle or at the tail "
"(needs one of the Board Break deck packs)",
Group::gameplay, argument("0|1", Type::boolean));
on.inspect = [unavailable](const Model &) {
const auto deck = board_break::deck();
return boolean_state(board_break::available(), board_break::enabled(), unavailable,
deck.empty() ? std::string("no Board Break deck is in the game") : "the " + board_break::deck_label(deck) + " deck");
};
on.run = [](const Model &, const Values &args, const Output &) { board_break::set_enabled(std::get<bool>(args[0])); };
registry.add(std::move(on));
auto which = argument("deck", Type::text, true);
which.complete = [](const Model &, auto) {
auto names = mods::installed_board_break_decks();
names.insert(names.begin(), "next");
return names;
};
auto deck = action("boardbreak deck", "Which deck every board has, by its name or its number, or next; the level loads again with it",
Group::gameplay, {std::move(which)});
deck.run = [](const Model &, const Values &args, const Output &out) {
if (!args.empty() && !std::get<std::string>(args[0]).empty()) {
out(board_break::choose_deck(std::get<std::string>(args[0])));
return;
}
// No deck named: which one is in the game, and which there are. The decks the feature was
// made with keep their numbers; the others follow.
const auto installed = mods::installed_board_break_decks();
const auto &known = mods::board_break_decks;
std::string list;
std::size_t number{};
const auto add = [&](std::string_view name, bool there) {
const auto place = ++number;
list += (place > 1 ? ", " : "") + std::to_string(place) + " " + board_break::deck_label(name) + (there ? "" : " (not installed)");
};
for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end());
for (const auto &name : installed)
if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true);
out("In the game: " + board_break::deck_label(board_break::deck()) + ". Decks: " + list + ".");
};
registry.add(std::move(deck));
auto where = argument("nose|mid|tail", Type::text, true);
where.complete = [](const Model &, auto) {
return std::vector<std::string>(board_break::place_names.begin(), board_break::place_names.end());
};
auto snap = action("boardbreak snap", "Break your board now, at the nose, in the middle, at the tail or wherever, to look at a break: "
"it stays broken until `boardbreak mend` or a respawn. A piece only comes off while you ride",
Group::gameplay, {std::move(where)});
snap.run = [](const Model &, const Values &args, const Output &out) {
const auto wanted = args.empty() ? std::string{} : std::get<std::string>(args[0]);
const auto &names = board_break::place_names;
const auto named = std::find(names.begin(), names.end(), wanted);
board_break::snap(named == names.end() ? -1 : static_cast<int>(named - names.begin()));
out(board_break::deck().empty() ? "No Board Break deck is in the game: the board holds." : "Snapped.");
};
registry.add(std::move(snap));
auto mend = action("boardbreak mend", "Make a broken board whole again", Group::gameplay);
mend.run = [](const Model &, const Values &, const Output &out) {
board_break::mend();
out("Mended.");
};
registry.add(std::move(mend));
}
} // namespace dingosdk::console
+206
View File
@@ -0,0 +1,206 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <string_view>
// Board Break's rules and geometry, apart from the game: where a board breaks, what is left of it,
// and where each part of a broken board lies. Everything is measured on the deck, in the deck
// model's own axes: metres, y up, z along the board with the nose at +z.
namespace dingosdk::board_break {
// ---- rigid motions ----------------------------------------------------------------------------
// Quaternions are x, y, z, w (Hamilton), as the game's poses store them.
struct Vec {
float x{}, y{}, z{};
};
struct Quat {
float x{}, y{}, z{}, w{1.0f};
};
constexpr Vec operator+(Vec a, Vec b) noexcept { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
constexpr Vec operator-(Vec a, Vec b) noexcept { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
constexpr Vec operator*(Vec a, float s) noexcept { return {a.x * s, a.y * s, a.z * s}; }
inline float length(Vec a) noexcept { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
constexpr Quat operator*(Quat a, Quat b) noexcept { // b first, then a
return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z};
}
constexpr Quat conj(Quat q) noexcept { return {-q.x, -q.y, -q.z, q.w}; }
inline Quat unit(Quat q) noexcept {
const auto n = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
return n > 1e-6f ? Quat{q.x / n, q.y / n, q.z / n, q.w / n} : Quat{};
}
inline Quat about(Vec axis, float radians) noexcept {
const auto s = std::sin(radians / 2);
return {axis.x * s, axis.y * s, axis.z * s, std::cos(radians / 2)};
}
constexpr Vec turn(Quat q, Vec v) noexcept {
const Vec u{q.x, q.y, q.z};
const Vec c{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x};
const Vec d{u.y * c.z - u.z * c.y, u.z * c.x - u.x * c.z, u.x * c.y - u.y * c.x};
return v + c * (2 * q.w) + d * 2;
}
// Turned, then shifted.
struct Rigid {
Quat facing{};
Vec at{};
};
// ---- breaks -----------------------------------------------------------------------------------
// A deck breaks across one of three rows: just ahead of the front bolts, in the middle, just behind
// the back bolts. That cuts it into four stretches; each truck rides on the stretch its bolts are in.
enum Piece : std::uint32_t { nose = 1, front = 2, back = 4, tail = 8, front_truck = 16, back_truck = 32 };
constexpr std::uint32_t whole_board = nose | front | back | tail | front_truck | back_truck;
constexpr std::uint32_t stretches = nose | front | back | tail;
enum class Place { nose, mid, tail };
constexpr std::array<std::string_view, 3> place_names{"nose", "mid", "tail"};
constexpr float nose_row = 0.260f, mid_row = 0.009f, tail_row = -0.260f;
// How the grip tape holds a break together: not at all, or with the nose hanging, the board folded
// in the middle, the tail hanging.
enum class Hinge { none, nose, fold, tail };
// What a break leaves: the piece the board keeps, the piece that comes off and flies (none when
// the grip tape holds), and how the two hang together.
struct Break {
std::uint32_t kept{}, loose{};
Hinge hinge{};
};
constexpr Break broken_at(Place place, bool joined) noexcept {
if (joined) return {whole_board, 0, place == Place::nose ? Hinge::nose : place == Place::mid ? Hinge::fold : Hinge::tail};
// A clean break: the board keeps the larger piece, and of two halves the front one.
switch (place) {
case Place::nose: return {whole_board & ~static_cast<std::uint32_t>(nose), nose, Hinge::none};
case Place::mid: return {nose | front | front_truck, back | tail | back_truck, Hinge::none};
default: return {whole_board & ~static_cast<std::uint32_t>(tail), tail, Hinge::none};
}
}
// How big a break is, for its sound. An end that hangs on by the grip tape is a small one; an end
// that comes off, or a board that folds a little, is in between; a board that folds right down or
// comes apart in the middle is the biggest. `fold_depth` is 0 to 1, how far a fold sags.
enum class Size { light, medium, heavy };
constexpr std::array<std::string_view, 3> size_names{"light", "medium", "heavy"};
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 {
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);
}
// ---- where the parts of a broken board lie ------------------------------------------------------
constexpr float axle_height = 0.028f, front_axle = 0.2226f, back_axle = -0.2225f;
// How far each half of a board tips: a touch short of where its first splinter meets the ground
// (0.351 and 0.325 rad, measured on the mesh), so the end rests on the ground instead of digging in.
constexpr float front_tip = 0.340f, back_tip = 0.315f;
// Half a board on one truck does not stand level: it tips about its axle until the broken end lies
// on the ground. `towards` is +1 when the broken end is on the nose side of that axle.
inline Rigid tipped_on_axle(float axle_z, float towards, float angle) noexcept {
const auto turned = about({1, 0, 0}, towards * angle);
const Vec axle{0, axle_height, axle_z};
return {turned, axle - turn(turned, axle)};
}
// A piece that hangs on by the grip tape: turned about the break line just under the deck's top,
// and drawn a hair away from the board, so the tape opens into a ragged crack with the wood of the
// broken end in it. `towards` is +1 for the nose side of the break.
constexpr float crack = 0.0025f; // how far the tape gapes along the board (m)
constexpr float grip_height = 0.0935f;
inline Rigid hung_on_grip(float break_z, float towards, float angle) noexcept {
const auto turned = about({1, 0, 0}, towards * angle);
const Vec hinge{0, grip_height - 0.004f, break_z};
return {turned, hinge - turn(turned, hinge) + Vec{0, 0, towards * crack * 0.5f}};
}
// A board folded in the middle: each half turns about its own axle, the front by `depth` of the
// way to the ground and the back as far as brings its grip to the same height, and the two move
// along the board until the grip of both all but meets again at the break, where the tape holds.
inline void folded(float depth, Rigid& front_half, Rigid& back_half) noexcept {
constexpr float grip_y = 0.095f;
constexpr float rise = grip_y - axle_height, ahead = front_axle - mid_row, behind = mid_row - back_axle;
const float a = front_tip * std::clamp(depth, 0.0f, 1.0f);
const float height = rise * std::cos(a) - ahead * std::sin(a); // of the grip at the break, above the axles
const float b = std::acos(std::clamp(height / std::hypot(rise, behind), -1.0f, 1.0f)) - std::atan2(behind, rise);
front_half = tipped_on_axle(front_axle, -1.0f, a);
back_half = tipped_on_axle(back_axle, 1.0f, b);
const float front_end = front_axle - (rise * std::sin(a) + ahead * std::cos(a));
const float back_end = back_axle + (rise * std::sin(b) + behind * std::cos(b));
front_half.at.z += (back_end - front_end + crack) * 0.5f; // and a crack's width more: the tape gapes
back_half.at.z -= (back_end - front_end + crack) * 0.5f;
}
// The deck sits on the board's rig a quarter turn about the upright axis (the rig's mount carries
// it), and the stretches hang on the rig's root: a motion worked out on the deck, carried over to
// the root's axes.
inline Rigid on_root(Quat mount, const Rigid& motion) noexcept {
return {unit(mount * motion.facing * conj(mount)), turn(mount, motion.at)};
}
// Where something goes that is not to be seen: far below the board. The game's replay keeps where
// a bone is and how it is turned, and nothing of its scale, so a part is never hidden by shrinking.
constexpr Vec away{0.0f, -2000.0f, 0.0f};
// One of the four stretches of deck (nose, front, back, tail, in that order everywhere): whether
// it shows, and its motion from the place it has in a whole board.
struct Stretch {
bool shown{};
Rigid motion{};
};
// A broken board as it lies: its stretches and its trucks, in the rig root's axes.
struct Layout {
std::array<Stretch, 4> stretch{};
bool front_truck{}, back_truck{};
Rigid front_motion{}, back_motion{};
};
// The piece a board keeps (`kept`, Piece bits), hanging together as `hinge` says. `angle` is how far
// an end droops (radians) or how deep a fold sags (0 to 1); `up` is how far the board is the right
// way up (1 on its wheels, -1 on its grip); `mount` is the rig's mount turn.
inline Layout lay_out(std::uint32_t kept, Hinge hinge, float angle, float up, Quat mount) noexcept {
// `ahead` moves the front of the board (and all of it unless told otherwise), `rear` the back half.
Rigid ahead{}, rear{}, hung{};
if (hinge == Hinge::fold) {
// The break sags towards the ground under the board. On its grip the fold would push nose
// and tail into the ground instead, so there it stays shallow.
const auto level = std::clamp((up + 0.2f) / 0.8f, 0.0f, 1.0f);
folded(angle * (0.3f + 0.7f * level), ahead, rear);
} else if (hinge == Hinge::nose) hung = hung_on_grip(nose_row, 1.0f, angle);
else if (hinge == Hinge::tail) hung = hung_on_grip(tail_row, -1.0f, angle);
else if (static_cast<bool>(kept & front_truck) != static_cast<bool>(kept & back_truck)) {
// Half a deck on one truck: while the board is the right way up it tips onto its broken end
// instead of standing level in the air.
const auto level = std::clamp((up - 0.3f) / 0.5f, 0.0f, 1.0f);
const auto amount = level * level * (3.0f - 2.0f * level);
ahead = kept & front_truck ? tipped_on_axle(front_axle, -1.0f, front_tip * amount) : tipped_on_axle(back_axle, 1.0f, back_tip * amount);
}
ahead = on_root(mount, ahead);
hung = on_root(mount, hung);
rear = hinge == Hinge::fold ? on_root(mount, rear) : ahead;
Layout out;
out.stretch = {{{(kept & nose) != 0, hinge == Hinge::nose ? hung : ahead},
{(kept & front) != 0, ahead},
{(kept & back) != 0, rear},
{(kept & tail) != 0, hinge == Hinge::fold ? rear : hinge == Hinge::tail ? hung : ahead}}};
out.front_truck = (kept & front_truck) != 0;
out.back_truck = (kept & back_truck) != 0;
out.front_motion = ahead;
out.back_motion = rear;
return out;
}
// What goes into the stretches' bones. The first three hang on the rig's root; the tail's hangs on
// the front stretch's, so its place is given from there. A stretch that does not show goes away.
inline std::array<Rigid, 4> bones_of(std::array<Stretch, 4> stretch) noexcept {
for (auto& one : stretch)
if (!one.shown) one.motion = {{}, away};
const auto& from = stretch[1].motion;
const auto& to = stretch[3].motion;
return {{stretch[0].motion, stretch[1].motion, stretch[2].motion,
{unit(conj(from.facing) * to.facing), turn(conj(from.facing), to.at - from.at)}}};
}
// A truck hangs on the deck's bone, which is moved `away` to hide the real deck, and that bone sits
// on the mount. To move the truck with a piece: the piece's motion seen from the deck, the deck's
// move taken back. `steer` is the truck's own turn, which it keeps; `place` where it sits on the deck.
constexpr Vec back_truck_place{0.0005f, -0.0183f, -0.2345f}, front_truck_place{0.0005f, -0.0183f, 0.2347f};
inline Rigid truck_bone(const Rigid& motion, Quat mount, Vec mount_at, Quat steer, Vec place) noexcept {
const auto facing = unit(conj(mount) * motion.facing * mount);
const auto at = turn(conj(mount), turn(motion.facing, mount_at) + motion.at - mount_at);
return {unit(facing * steer), turn(facing, place) + at - away};
}
} // namespace dingosdk::board_break
@@ -0,0 +1,87 @@
#include "Extension/Boot/discord_presence.h"
#include "Engine/Core/Json/json.h"
#include "Extension/Profile/local_profile_runtime.h"
#include <cstdint>
#include <cstring>
#include <iostream>
#include <optional>
// The profile is not part of this test: the switch is simply on.
namespace dingosdk::profile_runtime {
std::optional<bool> local_preference(std::string_view) noexcept { return std::nullopt; }
void set_local_preference(std::string_view, bool) noexcept {}
} // namespace dingosdk::profile_runtime
namespace {
int failures{};
void check(bool ok, const char *what) {
if (ok) return;
std::cout << "FAIL: " << what << '\n';
++failures;
}
} // namespace
int main() {
using namespace dingosdk;
using namespace dingosdk::discord_presence;
// A message is its opcode and length, little endian, then the JSON.
const auto bytes = frame(1, "{}");
std::uint32_t header[2]{};
std::memcpy(header, bytes.data(), sizeof(header));
check(bytes.size() == 10 && header[0] == 1 && header[1] == 2 && bytes.substr(8) == "{}", "A frame is not opcode, length and JSON");
// Alone: the map and how long, and nothing to join.
Presence solo{"On San Vansterdam", "Solo skating"};
auto sent = Json::parse(activity_command(&solo, 4321, 1790000000, 7));
const auto &alone = sent.at("args").at("activity");
check(sent.value("cmd", "") == "SET_ACTIVITY" && sent.value("nonce", "") == "7" && sent.at("args").at("pid").get<std::uint64_t>() == 4321,
"The command does not name this process");
check(alone.value("details", "") == "On San Vansterdam" && alone.value("state", "") == "Solo skating" &&
alone.at("timestamps").at("start").get<std::int64_t>() == 1790000000 && !alone.contains("party") && !alone.contains("secrets"),
"Skating alone does not show the map and the time alone");
// On a server with room: its name, how many, and the code behind the Join button.
Presence server{"On DHS", "Server: EU 1", 12, 32, "85568392924040001", "85568392924040001-00000000deadbeef"};
sent = Json::parse(activity_command(&server, 1, 1790000000, 8));
const auto &busy = sent.at("args").at("activity");
check(busy.at("party").value("id", "") == "85568392924040001" && busy.at("party").at("size").at(0).get<int>() == 12 &&
busy.at("party").at("size").at(1).get<int>() == 32 && busy.at("party").at("privacy").get<int>() == 1 &&
busy.at("secrets").value("join", "") == server.join,
"A server with room does not show its players and a way in");
// Full, or with no code that is one: no Join button.
auto full = server;
full.party_size = 32;
check(!Json::parse(activity_command(&full, 1, 0, 9)).at("args").at("activity").contains("secrets"), "A full server offers a way in");
auto closed = server;
closed.join.clear();
check(!Json::parse(activity_command(&closed, 1, 0, 9)).at("args").at("activity").contains("secrets"), "A session without a code offers a way in");
closed.join = "\"}],\"evil\":1";
check(!Json::parse(activity_command(&closed, 1, 0, 9)).at("args").at("activity").contains("secrets"), "Text that is not a join code was sent as one");
// A long name is cut to what Discord takes, and nothing clears the status.
Presence longer{std::string(200, 'x'), "y"};
const auto cut = Json::parse(activity_command(&longer, 1, 0, 1)).at("args").at("activity");
check(cut.value("details", "").size() == 128 && cut.value("state", "") == "y ", "Lines are not kept within Discord's lengths");
check(Json::parse(activity_command(nullptr, 1, 0, 1)).at("args").at("activity").is_null(), "Clearing the status does not send an empty one");
// Discord asking to join: only its own event, and only a code that is one.
check(join_request(R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN","data":{"secret":"76561198084159190-00000000deadbeef"}})") ==
"76561198084159190-00000000deadbeef",
"A request to join was not read");
for (const auto *wrong : {R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN","data":{"secret":"mp stop; quit"}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN","data":{"secret":"76561198084159190-00000000DEADBEEF"}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_SPECTATE","data":{"secret":"76561198084159190-00000000deadbeef"}})",
R"({"cmd":"SET_ACTIVITY","evt":null,"data":{"secret":"76561198084159190-00000000deadbeef"}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN"})", "not json"})
check(join_request(wrong).empty(), wrong);
// Someone asking to join: who, and only from Discord's own event.
check(join_asker(R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN_REQUEST","data":{"user":{"id":"53908232506183680","username":"Mason"}}})") ==
"53908232506183680",
"Who asked to join was not read");
for (const auto *wrong : {R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN_REQUEST","data":{"user":{"id":"1\"},\"x\":\""}}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN_REQUEST","data":{"user":{"id":53908232506183680}}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN","data":{"user":{"id":"53908232506183680"}}})",
R"({"cmd":"DISPATCH","evt":"ACTIVITY_JOIN_REQUEST","data":{}})"})
check(join_asker(wrong).empty(), wrong);
if (!failures) std::cout << "discord presence tests passed\n";
return failures ? 1 : 0;
}
@@ -0,0 +1,34 @@
#include "Extension/Boot/ea_service_block.h"
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
using namespace dingosdk;
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
bool blocked(std::string_view host) { return ea_service_host(host); }
bool blocked(std::wstring_view host) { return ea_service_host(host); }
static_assert(ea_service_host(std::string_view("collector.errors.ea.com")), "The match is usable at compile time");
int main() {
try {
// Every EA service the game contacted in a recorded session, and a sample of the others it names.
for (const auto* host : {"collector.errors.ea.com", "a-collector.errors.ea.com", "freeform-river.data.ea.com",
"gcs.ea.com", "pin-river-grpc.data.ea.com", "pin-em.data.ea.com", "spring25.client.blazeredirector.ea.com",
"spring18.gosredirector.ea.com", "accounts.ea.com", "gateway.grpc.ea.com", "experimentation-grpc.data.ea.com",
"adtech-runtime-rest-internal.data.ea.com", "river-grpc-lt.eadpdata.ddns.ea.com", "pn.tnt-ea.com",
"rtm.tnt-ea.com", "ea.com", "tnt-ea.com"})
check(blocked(host), std::string("EA service must be blocked: ") + host);
check(blocked("COLLECTOR.Errors.EA.com"), "Host names compare without case");
check(blocked("gcs.ea.com."), "A trailing root dot is the same name");
check(blocked(L"freeform-river.data.ea.com"), "Wide lookups are blocked too");
check(blocked(L"GCS.EA.COM."), "Wide lookups compare without case and root dot");
// What ReSkate and the game still need, and names that only look like EA's.
for (const auto* host : {"dingo-dev-assets.akamaized.net", "api.reskate.dev", "api.github.com",
"raw.githubusercontent.com", "thunderstore.io", "api.steampowered.com", "idea.com", "notea.com",
"xtnt-ea.com", "ea.com.example.net", "ea.co", "eacom", "com", ".", "", "159.153.1.1"})
check(!blocked(host), std::string("Host must stay reachable: ") + host);
check(!blocked(L"dingo-dev-assets.akamaized.net"), "The fast-travel artwork CDN stays reachable");
check(!blocked(L"gcs.ea.cöm"), "Characters outside ASCII never match a domain letter");
check(!blocked("gcs.ea.com.."), "Only one root dot is stripped");
std::cout << "EA service block checks passed.\n";
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
}
+312
View File
@@ -0,0 +1,312 @@
#include "discord_presence.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Profile/local_profile_runtime.h"
#include <Windows.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <optional>
#include <thread>
#include <utility>
namespace dingosdk::discord_presence {
namespace {
// Discord's own limits: a line is 2 to 128 characters.
std::string line(std::string text) {
if (text.size() > 128) {
text.resize(125);
// Not in the middle of a UTF-8 character.
while (!text.empty() && (static_cast<unsigned char>(text.back()) & 0xc0) == 0x80) text.pop_back();
if (!text.empty() && static_cast<unsigned char>(text.back()) >= 0xc0) text.pop_back();
text += "...";
}
if (text.size() == 1) text += ' ';
return text;
}
std::mutex mutex;
Presence wanted; // under mutex
long long wanted_since{}; // Unix seconds: when what the player is doing last changed kind
std::atomic<bool> started{}, on{true};
std::string join_wanted; // under mutex: take_join
// A join code is "<SteamID64>-<16 hex digits>" (protocol.h: format_invite). Discord hands back
// whatever a status carried, which need not be one of ours.
bool join_code(std::string_view text) {
const auto dash = text.find('-');
if (dash == std::string_view::npos || dash < 15 || dash > 20 || text.size() - dash - 1 != 16) return false;
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (i == dash) continue;
if (!(c >= '0' && c <= '9') && !(i > dash && c >= 'a' && c <= 'f')) return false;
}
return true;
}
// Tells Discord how to start ReSkate for someone who presses Join while it is not running:
// the URL scheme Discord opens for an application ("discord-<id>:"), pointing at the launcher
// beside the game. In the player's own part of the registry.
void register_launcher() noexcept {
try {
std::wstring game(MAX_PATH, L'\0');
game.resize(GetModuleFileNameW(nullptr, game.data(), static_cast<DWORD>(game.size())));
const auto slash = game.find_last_of(L"\\/");
if (game.empty() || game.size() >= MAX_PATH || slash == std::wstring::npos) return;
const auto launcher = game.substr(0, slash + 1) + L"ReSkateLauncher.exe";
if (GetFileAttributesW(launcher.c_str()) == INVALID_FILE_ATTRIBUTES) return;
const auto scheme = L"discord-" + std::wstring(application_id.begin(), application_id.end());
const auto put = [](const std::wstring &key, const wchar_t *name, const std::wstring &value) {
RegSetKeyValueW(HKEY_CURRENT_USER, key.c_str(), name, REG_SZ, value.c_str(), static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t)));
};
const auto root = L"Software\\Classes\\" + scheme;
put(root, nullptr, L"URL:Run ReSkate");
put(root, L"URL Protocol", L"");
// --no-gui: straight into the game, which is what pressing Join asked for.
put(root + L"\\shell\\open\\command", nullptr, L"\"" + launcher + L"\" --no-gui");
} catch (...) {}
}
long long unix_now() {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
// The Discord app's pipe, one message at a time. Everything gives up quietly: Discord may
// not be running, may close, or may be a build that answers differently.
class Pipe {
public:
~Pipe() { close(); }
bool open() {
close();
for (int n = 0; n < 10; ++n) {
const auto name = L"\\\\.\\pipe\\discord-ipc-" + std::to_wstring(n);
handle_ = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (handle_ != INVALID_HANDLE_VALUE) return true;
}
return false;
}
void close() {
if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_);
handle_ = INVALID_HANDLE_VALUE;
}
bool connected() const { return handle_ != INVALID_HANDLE_VALUE; }
bool send(unsigned opcode, std::string_view json) {
const auto bytes = frame(opcode, json);
DWORD written{};
if (connected() && WriteFile(handle_, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr) && written == bytes.size())
return true;
close();
return false;
}
// The next message if one has arrived (never waits): its opcode, and its JSON in `json`.
std::optional<unsigned> receive(std::string &json) {
DWORD waiting{};
if (!connected()) return std::nullopt;
if (!PeekNamedPipe(handle_, nullptr, 0, nullptr, &waiting, nullptr)) return close(), std::nullopt;
if (waiting < 8) return std::nullopt;
std::uint32_t header[2]{};
DWORD got{};
if (!ReadFile(handle_, header, sizeof(header), &got, nullptr) || got != sizeof(header) || header[1] > 64 * 1024)
return close(), std::nullopt;
json.assign(header[1], '\0');
for (DWORD at = 0; at < header[1]; at += got)
if (!ReadFile(handle_, json.data() + at, header[1] - at, &got, nullptr) || !got) return close(), std::nullopt;
return header[0];
}
private:
HANDLE handle_ = INVALID_HANDLE_VALUE;
};
void run() noexcept {
try {
Pipe pipe;
std::optional<Presence> shown; // what Discord has, while connected
std::uint64_t next_connect{}, next_send{}, nonce{};
bool ready{}, said{};
register_launcher();
for (;;) {
Sleep(1000);
const auto now = GetTickCount64();
const bool wants = on.load(std::memory_order_relaxed);
if (!pipe.connected()) {
ready = false;
shown.reset();
if (!wants || now < next_connect) continue;
next_connect = now + 30000;
if (!pipe.open() || !pipe.send(0, "{\"v\":1,\"client_id\":\"" + std::string(application_id) + "\"}")) continue;
}
// Discord's answers: READY after the handshake, a reply to each command, pings.
std::string json;
while (const auto opcode = pipe.receive(json)) {
if (*opcode == 3) pipe.send(4, json); // PING -> PONG
else if (*opcode == 2) pipe.close(); // CLOSE (an application ID it does not know, for one)
else if (*opcode == 1 && ready) {
// Someone pressed Join on a status or an invite: theirs to act on (take_join).
if (auto code = join_request(json); !code.empty()) {
logging::log(logging::Level::info, logging::Channel::runtime, "Discord: asked to join a session.");
std::lock_guard lock(mutex);
join_wanted = std::move(code);
} else if (const auto asker = join_asker(json); !asker.empty() && shown && !shown->join.empty() && wants) {
// "Ask to Join" on a session anyone may walk into: yes, without asking the player.
pipe.send(1, "{\"cmd\":\"SEND_ACTIVITY_JOIN_INVITE\",\"args\":{\"user_id\":\"" + asker + "\"},\"nonce\":\"" +
std::to_string(++nonce) + "\"}");
logging::log(logging::Level::info, logging::Channel::runtime, "Discord: let someone who asked into this session.");
}
} else if (*opcode == 1 && json.find("\"READY\"") != std::string::npos) {
ready = true;
// Discord then tells this game when its player asks to join someone.
pipe.send(1, "{\"cmd\":\"SUBSCRIBE\",\"evt\":\"ACTIVITY_JOIN\",\"nonce\":\"" + std::to_string(++nonce) + "\"}");
// And when someone asks to join this player.
pipe.send(1, "{\"cmd\":\"SUBSCRIBE\",\"evt\":\"ACTIVITY_JOIN_REQUEST\",\"nonce\":\"" + std::to_string(++nonce) + "\"}");
if (!std::exchange(said, true))
logging::log(logging::Level::info, logging::Channel::runtime, "Discord: connected; showing what you are doing as your status.");
}
}
if (!pipe.connected() || !ready) continue;
if (!wants) {
// Switched off: take the status down, and leave Discord alone.
if (shown) pipe.send(1, activity_command(nullptr, GetCurrentProcessId(), 0, ++nonce));
pipe.close();
continue;
}
Presence presence;
long long since{};
{
std::lock_guard lock(mutex);
presence = wanted;
since = wanted_since;
}
// Discord takes a few updates in twenty seconds and drops the rest.
if (shown == presence || now < next_send) continue;
if (pipe.send(1, activity_command(&presence, GetCurrentProcessId(), since, ++nonce))) {
shown = std::move(presence);
next_send = now + 5000;
}
}
} catch (...) {}
}
} // namespace
std::string frame(unsigned opcode, std::string_view json) {
std::string out(8, '\0');
const std::uint32_t header[2]{opcode, static_cast<std::uint32_t>(json.size())};
std::memcpy(out.data(), header, sizeof(header));
out += json;
return out;
}
std::string activity_command(const Presence *presence, unsigned long process, long long started_at, unsigned long long nonce) {
auto args = Json::object();
args["pid"] = static_cast<std::uint64_t>(process);
if (presence) {
auto activity = Json::object();
if (!presence->details.empty()) activity["details"] = line(presence->details);
if (!presence->state.empty()) activity["state"] = line(presence->state);
if (started_at > 0) {
auto timestamps = Json::object();
timestamps["start"] = static_cast<std::int64_t>(started_at);
activity["timestamps"] = std::move(timestamps);
}
if (presence->party_size > 0 && presence->party_most >= presence->party_size && !presence->party.empty()) {
auto party = Json::object();
party["id"] = presence->party;
party["size"] = Json::array({Json(presence->party_size), Json(presence->party_most)});
activity["party"] = std::move(party);
// The Join button: only with room left, and only a code that is one.
if (presence->party_size < presence->party_most && join_code(presence->join)) {
auto secrets = Json::object();
secrets["join"] = presence->join;
activity["secrets"] = std::move(secrets);
// A public party: Discord lets people join it outright ("Join") where a
// private one makes them ask first ("Ask to Join"). The code is only here
// for a session anyone may walk into.
activity["party"]["privacy"] = 1;
}
}
auto assets = Json::object();
assets["large_image"] = "resk8";
assets["large_text"] = "ReSkate";
activity["assets"] = std::move(assets);
args["activity"] = std::move(activity);
} else {
args["activity"] = Json();
}
auto command = Json::object();
command["cmd"] = "SET_ACTIVITY";
command["args"] = std::move(args);
command["nonce"] = std::to_string(nonce);
return command.dump();
}
std::string join_request(std::string_view json) {
try {
const auto message = Json::parse(json);
if (!message.is_object() || message.value("cmd", "") != "DISPATCH" || message.value("evt", "") != "ACTIVITY_JOIN" ||
!message.contains("data") || !message.at("data").is_object())
return {};
auto code = message.at("data").value("secret", "");
return join_code(code) ? code : std::string();
} catch (...) {
return {};
}
}
std::string join_asker(std::string_view json) {
try {
const auto message = Json::parse(json);
if (!message.is_object() || message.value("cmd", "") != "DISPATCH" || message.value("evt", "") != "ACTIVITY_JOIN_REQUEST" ||
!message.contains("data") || !message.at("data").is_object() || !message.at("data").contains("user") ||
!message.at("data").at("user").is_object())
return {};
// A Discord user ID is a number, sent as text.
auto id = message.at("data").at("user").value("id", "");
const bool digits = !id.empty() && id.size() <= 20 && id.find_first_not_of("0123456789") == std::string::npos;
return digits ? id : std::string();
} catch (...) {
return {};
}
}
std::string take_join() noexcept {
try {
std::lock_guard lock(mutex);
return std::exchange(join_wanted, {});
} catch (...) {
return {};
}
}
bool available() noexcept {
static const bool allowed = [] {
// The launcher says (launch.cpp): "0" is its switch turned off.
wchar_t value[2]{};
const auto length = GetEnvironmentVariableW(L"RESKATE_DISCORD", value, 2);
return !(length == 1 && value[0] == L'0');
}();
return allowed && !application_id.empty();
}
bool enabled() noexcept { return on.load(std::memory_order_relaxed); }
void set_enabled(bool value) noexcept {
on.store(value, std::memory_order_relaxed);
profile_runtime::set_local_preference("DiscordPresence", value);
}
void update(Presence presence) noexcept {
if (!available()) return;
try {
on.store(profile_runtime::local_preference("DiscordPresence").value_or(true), std::memory_order_relaxed);
{
std::lock_guard lock(mutex);
// The timer under the status counts from when the player went into or out of a
// session, not from every change of map or player count.
if (!wanted_since || wanted.party != presence.party) wanted_since = unix_now();
wanted = std::move(presence);
}
if (on.load(std::memory_order_relaxed) && !started.exchange(true)) std::thread(run).detach();
} catch (...) {}
}
} // namespace dingosdk::discord_presence
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <string>
#include <string_view>
// Discord Rich Presence: what the player is doing in ReSkate, shown on their Discord profile
// ("On San Vansterdam", "Server: EU 1", 12 of 32). The Discord app on the same PC takes it over
// its local pipe; nothing goes to Discord's servers from here, and without the app running
// nothing happens at all. Players can turn it off (the menu's Multiplayer settings).
//
// A session anyone may walk into (no password, not full, a server or a public lobby) also
// carries its join code, which gives the status Discord's Join button. Whoever presses it has
// Discord start ReSkate (the launcher, registered for that below) if it is not running, and
// then hand their game the code (take_join).
namespace dingosdk::discord_presence {
// The Discord application the presence belongs to (its name is what Discord shows as the game):
// the Application ID from discord.com/developers/applications. Empty: no presence at all.
// Its Rich Presence art asset named "resk8" is the picture.
inline constexpr std::string_view application_id = "1558319241114025986";
struct Presence {
std::string details, state; // the two lines under the name; empty: left out
int party_size{}, party_most{}; // "(3 of 16)" after the state; 0: left out
std::string party; // what is the same for everyone in one session
std::string join; // the session's join code, when anyone may join it; else empty
bool operator==(const Presence &) const = default;
};
// Whether there is any Discord status in this run: there is an application for it, and the
// launcher's own switch (Settings, "Discord status"; --no-discord) has not turned it off. Off
// there, the game never opens Discord's pipe, and the in-game switch is not offered.
bool available() noexcept;
// What to show from now on (client thread, as often as it likes: only a change is sent).
void update(Presence presence) noexcept;
// The player's own switch, kept in their profile. On unless they turned it off.
bool enabled() noexcept;
void set_enabled(bool on) noexcept;
// The join code of a session the player asked to join from Discord (a Join button, or an
// invite in a chat), once; empty when there is none. Any thread.
std::string take_join() noexcept;
// What Discord sent over its pipe, if it is a request to join: the code, or empty.
std::string join_request(std::string_view json);
// And if it is someone asking to join this player ("Ask to Join"): their Discord user ID, or
// empty. A status only has that button while its session is open to anyone, so the game says
// yes for the player at once, and Discord then lets them in as Join would.
std::string join_asker(std::string_view json);
// One message as Discord's pipe takes it: an opcode, a length and JSON. Exposed for tests.
std::string frame(unsigned opcode, std::string_view json);
// The command that shows `presence` (or, without one, clears it), since `started` (Unix seconds).
std::string activity_command(const Presence *presence, unsigned long process, long long started, unsigned long long nonce);
} // namespace dingosdk::discord_presence
+168
View File
@@ -0,0 +1,168 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <Windows.h>
#include "ea_service_block.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
#include <atomic>
#include <mutex>
#include <set>
#include <stdexcept>
#include <string>
#include <string_view>
namespace dingosdk {
namespace {
using GetAddrInfoAFn = INT (WSAAPI*)(PCSTR, PCSTR, const ADDRINFOA*, PADDRINFOA*);
using GetAddrInfoWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, const ADDRINFOW*, PADDRINFOW*);
using GetAddrInfoExAFn = INT (WSAAPI*)(PCSTR, PCSTR, DWORD, LPGUID, const ADDRINFOEXA*, PADDRINFOEXA*, timeval*,
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
using GetAddrInfoExWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, DWORD, LPGUID, const ADDRINFOEXW*, PADDRINFOEXW*, timeval*,
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
using GetHostByNameFn = hostent* (WSAAPI*)(const char*);
std::atomic<GetAddrInfoAFn> original_get_addr_info_a{};
std::atomic<GetAddrInfoWFn> original_get_addr_info_w{};
std::atomic<GetAddrInfoExAFn> original_get_addr_info_ex_a{};
std::atomic<GetAddrInfoExWFn> original_get_addr_info_ex_w{};
std::atomic<GetHostByNameFn> original_get_host_by_name{};
// A refused name is logged once at info level; a service that cannot connect keeps retrying, so
// repeats are logged at debug level only. Never destroyed, because lookups can still arrive while
// the process exits.
struct RefusedNames {
std::mutex mutex;
std::set<std::wstring> names;
};
RefusedNames& refused_names() {
static auto* value = new RefusedNames;
return *value;
}
template<class Char>
bool refuse(const Char* name) noexcept {
if (!name) return false;
const std::basic_string_view<Char> host(name);
if (!ea_service_host(host)) return false;
try {
std::wstring wide;
wide.reserve(host.size());
for (const auto ch : host) wide.push_back(static_cast<wchar_t>(static_cast<std::make_unsigned_t<Char>>(ch)));
bool first{};
{
auto& refused = refused_names();
const std::lock_guard lock(refused.mutex);
first = refused.names.insert(wide).second;
}
if (first)
logging::write(logging::Level::info, logging::Channel::runtime,
L"Blocked the game's lookup of " + wide + L": ReSkate keeps it off EA's online services");
else if (logging::enabled(logging::Level::debug))
logging::write(logging::Level::debug, logging::Channel::runtime, L"Blocked the game's lookup of " + wide + L" again");
} catch (...) {}
return true;
}
// What the resolver itself answers for a name that does not exist.
template<class Result>
INT not_found(Result* result) noexcept {
if (result) *result = nullptr;
WSASetLastError(WSAHOST_NOT_FOUND);
return WSAHOST_NOT_FOUND;
}
INT WSAAPI get_addr_info_a(PCSTR name, PCSTR service, const ADDRINFOA* hints, PADDRINFOA* result) {
if (refuse(name)) return not_found(result);
return original_get_addr_info_a.load()(name, service, hints, result);
}
INT WSAAPI get_addr_info_w(PCWSTR name, PCWSTR service, const ADDRINFOW* hints, PADDRINFOW* result) {
if (refuse(name)) return not_found(result);
return original_get_addr_info_w.load()(name, service, hints, result);
}
// Failing before the lookup starts is a valid answer for an overlapped call too: the completion
// routine and event are only used once the function has returned WSA_IO_PENDING.
INT WSAAPI get_addr_info_ex_a(PCSTR name, PCSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXA* hints,
PADDRINFOEXA* result, timeval* timeout, LPOVERLAPPED overlapped,
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
if (refuse(name)) {
if (handle) *handle = nullptr;
return not_found(result);
}
return original_get_addr_info_ex_a.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
completion, handle);
}
INT WSAAPI get_addr_info_ex_w(PCWSTR name, PCWSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXW* hints,
PADDRINFOEXW* result, timeval* timeout, LPOVERLAPPED overlapped,
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
if (refuse(name)) {
if (handle) *handle = nullptr;
return not_found(result);
}
return original_get_addr_info_ex_w.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
completion, handle);
}
hostent* WSAAPI get_host_by_name(const char* name) {
if (refuse(name)) {
WSASetLastError(WSAHOST_NOT_FOUND);
return nullptr;
}
return original_get_host_by_name.load()(name);
}
// `required`: the entry point the game itself resolves names through; without it nothing is
// blocked, and that is an error. The others close the same door for anything else loaded into
// the process, and are left alone where they cannot be hooked: Wine's ws2_32 (Proton, on Linux
// and the Steam Deck) does not have all of them, and what it does not have nothing can call.
template<class Fn>
void hook(const char* name, void* replacement, std::atomic<Fn>& original, bool required) {
const auto fail = [&](const std::string& why) {
if (required) throw std::runtime_error(why);
logging::log(logging::Level::info, logging::Channel::runtime, "EA online services block: {} left as it is ({}).", name, why);
};
const auto module = LoadLibraryExW(L"ws2_32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
const auto target = module ? reinterpret_cast<void*>(GetProcAddress(module, name)) : nullptr;
if (!target) return fail(std::string("Missing ") + name);
void* relay{};
const auto create = hook_prepare(target, replacement, &relay);
if (create != HookOk || !relay) {
if (create == HookOk) (void)hook_remove(target);
return fail(std::string("Cannot hook ") + name + ": " + hook_status_string(create == HookOk ? HookUnsupportedFunction : create));
}
original.store(reinterpret_cast<Fn>(relay));
const auto enable = hook_enable(target);
if (enable != HookOk) {
(void)hook_remove(target);
return fail(std::string("Cannot enable ") + name + ": " + hook_status_string(enable));
}
}
} // namespace
bool start_ea_service_block(std::string& error) noexcept {
try {
// Skate.exe resolves names through getaddrinfo alone; the other entry points close the
// same door for anything else loaded into the process.
#pragma warning(push)
#pragma warning(disable: 4191)
hook("getaddrinfo", reinterpret_cast<void*>(&get_addr_info_a), original_get_addr_info_a, true);
hook("GetAddrInfoW", reinterpret_cast<void*>(&get_addr_info_w), original_get_addr_info_w, false);
hook("GetAddrInfoExA", reinterpret_cast<void*>(&get_addr_info_ex_a), original_get_addr_info_ex_a, false);
hook("GetAddrInfoExW", reinterpret_cast<void*>(&get_addr_info_ex_w), original_get_addr_info_ex_w, false);
hook("gethostbyname", reinterpret_cast<void*>(&get_host_by_name), original_get_host_by_name, false);
#pragma warning(pop)
error.clear();
return true;
} catch (const std::exception& exception) {
error = exception.what();
return false;
}
}
} // namespace dingosdk
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more