mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 07:45:53 +02:00
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:
commit
f027d319be
391 files changed
+47172
-5566
No files matched your search
@@ -3,6 +3,7 @@
|
||||
/artifacts/
|
||||
/logs/
|
||||
/.vs/
|
||||
/CMakeUserPresets.json
|
||||
__pycache__/
|
||||
*.log
|
||||
*.dll
|
||||
|
||||
+129
-8
@@ -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)$")
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]] = '*';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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}};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}};
|
||||
}
|
||||
@@ -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}};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, ¬es);
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ¬e : 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ®istry) {
|
||||
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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user