diff --git a/.gitignore b/.gitignore index fc01b387..2f75651c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /artifacts/ /logs/ /.vs/ +/CMakeUserPresets.json __pycache__/ *.log *.dll diff --git a/CMakeLists.txt b/CMakeLists.txt index 34277fca..4d820c35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 + $<$: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 $<$:dingosdk_https>) + target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json dingosdk_word_filter $<$: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)$") diff --git a/Engine/Core/Console/command_registry.h b/Engine/Core/Console/command_registry.h index 33be1c14..841aa0bf 100644 --- a/Engine/Core/Console/command_registry.h +++ b/Engine/Core/Console/command_registry.h @@ -215,6 +215,8 @@ template 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)); diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index 4517e929..64a72a4c 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -6,6 +6,7 @@ #include #include #else +#include #include #include #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 bytes(1024 * 1024); diff --git a/Engine/Core/Platform/launcher_support.cpp b/Engine/Core/Platform/launcher_support.cpp index d2961e83..818afb9a 100644 --- a/Engine/Core/Platform/launcher_support.cpp +++ b/Engine/Core/Platform/launcher_support.cpp @@ -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& 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(index) + 1); + } + arguments.erase(arguments.begin() + static_cast(index)); + } + return map; +} + LaunchOptions parse_launch_options(const std::vector& arguments) { LaunchOptions options; bool log_level_explicit = false; @@ -181,7 +214,17 @@ LaunchOptions parse_launch_options(const std::vector& 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)); diff --git a/Engine/Core/Platform/launcher_support.h b/Engine/Core/Platform/launcher_support.h index 079aa37f..1e156047 100644 --- a/Engine/Core/Platform/launcher_support.h +++ b/Engine/Core/Platform/launcher_support.h @@ -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 : 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 game_arguments; }; // Arguments exclude argv[0]. Saved game display settings apply by default. // --windowed or an explicit width/height requests the windowed override. LaunchOptions parse_launch_options(const std::vector& arguments); +// Takes "-map " (--map, -map=) 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& 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, diff --git a/Engine/Core/Platform/memory.h b/Engine/Core/Platform/memory.h index edc53d78..589416be 100644 --- a/Engine/Core/Platform/memory.h +++ b/Engine/Core/Platform/memory.h @@ -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(address); - for (std::size_t i = 0; i < capacity; ++i) - if (!(destination[i] = source[i])) return static_cast(i); + for (std::size_t i = 0; i < capacity; ++i) { + destination[i] = source[i]; + if (destination[i] == '\0') return static_cast(i); + } return -1; } __except (EXCEPTION_EXECUTE_HANDLER) { return -1; diff --git a/Engine/Core/Profiling/sampler.cpp b/Engine/Core/Profiling/sampler.cpp index 3d93b7da..0f201cf6 100644 --- a/Engine/Core/Profiling/sampler.cpp +++ b/Engine/Core/Profiling/sampler.cpp @@ -186,6 +186,7 @@ const HANDLE symbol_session = reinterpret_cast(static_cast 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--symbols) name it afterwards. + const auto here = reinterpret_cast(&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 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(used), static_cast(t), static_cast(depth)}); + records.push_back({static_cast(used), static_cast(t), static_cast(depth), + static_cast(std::chrono::duration_cast(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 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(1'000'000 / request.rate); + const auto stack_of = [&](const Record& record, std::vector& 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& 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(f); + return -1; + }; + struct Frame { std::size_t first{}, last{}; std::uint32_t us{}; }; + std::vector frames; + std::vector 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 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 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(f)]]; + counts.module = owners[walked[static_cast(f)]]; + if (!f) ++counts.self; + const auto here = walked.begin() + f; + if (std::find(walked.begin(), here, walked[static_cast(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> 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 threads) { out << "Skate.exe functions are Ghidra addresses (image base 0x140000000). Name them in\n" "ReSkate.labels.tsv next to Skate.exe (\"
\" 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% diff --git a/Engine/Core/Text/Test/word_filter_tests.cpp b/Engine/Core/Text/Test/word_filter_tests.cpp index 1cdae7c3..2c764493 100644 --- a/Engine/Core/Text/Test/word_filter_tests.cpp +++ b/Engine/Core/Text/Test/word_filter_tests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include 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 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 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 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; } diff --git a/Engine/Core/Text/bad_words.txt b/Engine/Core/Text/bad_words.txt deleted file mode 100644 index 07859417..00000000 --- a/Engine/Core/Text/bad_words.txt +++ /dev/null @@ -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 diff --git a/Engine/Core/Text/word_filter.cpp b/Engine/Core/Text/word_filter.cpp index d566e649..0989948b 100644 --- a/Engine/Core/Text/word_filter.cpp +++ b/Engine/Core/Text/word_filter.cpp @@ -1,11 +1,14 @@ #include "word_filter.h" -#include "embedded_bad_words.h" - #include #include +#include #include #include +#include +#include +#include +#include #include #include @@ -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 no_words() { + static const std::shared_ptr rules = std::make_shared(); + 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::vector>> given; + std::vector own_filtered, own_forbidden; + bool use_given = true; +}; +std::mutex sources_mutex; +Sources sources; +std::atomic> made; +std::shared_ptr 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(); + 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(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 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* marked) { - const auto& l = lists(); +bool match(const Lists& l, const Word& word, std::vector* 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(start), @@ -156,16 +234,47 @@ bool match(const Word& word, std::vector* 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 filtered, std::span forbidden) { + std::lock_guard lock(sources_mutex); + sources.given.emplace(std::vector(filtered.begin(), filtered.end()), + std::vector(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 filtered, std::span 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 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]] = '*'; } diff --git a/Engine/Core/Text/word_filter.h b/Engine/Core/Text/word_filter.h index 6c66d4ef..f506e4f6 100644 --- a/Engine/Core/Text/word_filter.h +++ b/Engine/Core/Text/word_filter.h @@ -1,19 +1,39 @@ #pragma once +#include #include #include -// 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 filtered, std::span 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 filtered, std::span 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); diff --git a/Engine/Game/Abi/Test/linear_transform_tests.cpp b/Engine/Game/Abi/Test/linear_transform_tests.cpp new file mode 100644 index 00000000..267235e7 --- /dev/null +++ b/Engine/Game/Abi/Test/linear_transform_tests.cpp @@ -0,0 +1,45 @@ +// LinearTransform math: points placed, directions turned, transforms composed. +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +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; +} diff --git a/Engine/Game/Abi/linear_transform.h b/Engine/Game/Abi/linear_transform.h new file mode 100644 index 00000000..97c9ec6f --- /dev/null +++ b/Engine/Game/Abi/linear_transform.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include + +// 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; +using LinearTransform = std::array, 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; +} +} diff --git a/Engine/Game/Build/20260929/entity_pages.h b/Engine/Game/Build/20260929/entity_pages.h index 1052a85b..fa4068cd 100644 --- a/Engine/Game/Build/20260929/entity_pages.h +++ b/Engine/Game/Build/20260929/entity_pages.h @@ -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 diff --git a/Engine/Game/Build/20260929/hud_corner.h b/Engine/Game/Build/20260929/hud_corner.h new file mode 100644 index 00000000..1e82c495 --- /dev/null +++ b/Engine/Game/Build/20260929/hud_corner.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include + +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; +} diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index b472fe4b..9b0dad39 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -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 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}}, diff --git a/Engine/Game/Build/20260929/media_catalog.h b/Engine/Game/Build/20260929/media_catalog.h new file mode 100644 index 00000000..b0b9273d --- /dev/null +++ b/Engine/Game/Build/20260929/media_catalog.h @@ -0,0 +1,38 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include + +// 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 / +// (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; +} diff --git a/Engine/Game/Build/20260929/native_cosmetics.h b/Engine/Game/Build/20260929/native_cosmetics.h index 97f06930..c169c63b 100644 --- a/Engine/Game/Build/20260929/native_cosmetics.h +++ b/Engine/Game/Build/20260929/native_cosmetics.h @@ -35,4 +35,18 @@ inline constexpr std::uintptr_t named_lookup = 0x16fc900; inline constexpr std::array 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 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 script_preset_reference_prefix{0x4c,0x8d,0x05,0x81,0xa2,0x0d,0x03,0xe9}; +inline constexpr std::uintptr_t referenced_preset = 0x16fe9c0; +inline constexpr std::array referenced_preset_prefix{ + 0x48,0x8b,0x09,0x48,0x85,0xc9,0x74,0x07,0x48,0x8b,0x01,0x48,0xff,0x60,0x18,0x33,0xc0}; } diff --git a/Engine/Game/Build/20260929/native_vfx.h b/Engine/Game/Build/20260929/native_vfx.h new file mode 100644 index 00000000..b8577076 --- /dev/null +++ b/Engine/Game/Build/20260929/native_vfx.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +// 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 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; +} diff --git a/Engine/Game/Build/20260929/offboard_flight.h b/Engine/Game/Build/20260929/offboard_flight.h index c0d65407..5d2c731f 100644 --- a/Engine/Game/Build/20260929/offboard_flight.h +++ b/Engine/Game/Build/20260929/offboard_flight.h @@ -35,4 +35,16 @@ inline constexpr std::array 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, 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}}; } diff --git a/Engine/Game/Build/20260929/pending_updates.h b/Engine/Game/Build/20260929/pending_updates.h index 0699a247..6f3f266c 100644 --- a/Engine/Game/Build/20260929/pending_updates.h +++ b/Engine/Game/Build/20260929/pending_updates.h @@ -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 diff --git a/Engine/Game/Build/20260929/replay_recorder.h b/Engine/Game/Build/20260929/replay_recorder.h new file mode 100644 index 00000000..7f05cffd --- /dev/null +++ b/Engine/Game/Build/20260929/replay_recorder.h @@ -0,0 +1,19 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include + +// 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; +} diff --git a/Engine/Game/Build/20260929/skater_body.h b/Engine/Game/Build/20260929/skater_body.h new file mode 100644 index 00000000..c7da2f56 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_body.h @@ -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 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 +} diff --git a/Engine/Game/Build/20260929/skater_render.h b/Engine/Game/Build/20260929/skater_render.h new file mode 100644 index 00000000..349ade1c --- /dev/null +++ b/Engine/Game/Build/20260929/skater_render.h @@ -0,0 +1,79 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +#include +#include + +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 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 contracts{draw_packet_contract, first_lookup_contract, + second_lookup_contract, render_view_contract}; +} diff --git a/Engine/Game/Build/20260929/skater_skeleton.h b/Engine/Game/Build/20260929/skater_skeleton.h new file mode 100644 index 00000000..84104973 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_skeleton.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +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; +} diff --git a/Engine/Game/Build/20260929/skater_state.h b/Engine/Game/Build/20260929/skater_state.h new file mode 100644 index 00000000..3ff12d22 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_state.h @@ -0,0 +1,47 @@ +#pragma once +#include "client_source_spawn.h" +#include +#include + +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 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; +} diff --git a/Engine/Game/Build/20260929/spatial_buckets.h b/Engine/Game/Build/20260929/spatial_buckets.h index 75d48260..db44b002 100644 --- a/Engine/Game/Build/20260929/spatial_buckets.h +++ b/Engine/Game/Build/20260929/spatial_buckets.h @@ -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); diff --git a/Engine/Game/Build/20260929/ui_model.h b/Engine/Game/Build/20260929/ui_model.h new file mode 100644 index 00000000..5759df41 --- /dev/null +++ b/Engine/Game/Build/20260929/ui_model.h @@ -0,0 +1,15 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include + +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}}; +} diff --git a/Engine/Game/Build/20260929/ui_textures.h b/Engine/Game/Build/20260929/ui_textures.h new file mode 100644 index 00000000..8c09d540 --- /dev/null +++ b/Engine/Game/Build/20260929/ui_textures.h @@ -0,0 +1,71 @@ +#pragma once +#include + +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}}; +} diff --git a/Engine/Game/Build/20260929/unload_guard.h b/Engine/Game/Build/20260929/unload_guard.h new file mode 100644 index 00000000..072981dd --- /dev/null +++ b/Engine/Game/Build/20260929/unload_guard.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +// 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 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 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(patch[49]) == 0 - 50 && static_cast(contract[49]) == 2 - 50); +} diff --git a/Engine/Game/Input/controller_bindings.h b/Engine/Game/Input/controller_bindings.h index f833dac1..639ed1e3 100644 --- a/Engine/Game/Input/controller_bindings.h +++ b/Engine/Game/Input/controller_bindings.h @@ -1,5 +1,7 @@ #pragma once +#include #include +#include #include #include #include @@ -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(key - '0' + 1); + if (key >= 'A' && key <= 'Z') return static_cast(key - 'A' + 11); + if (key == 0x20) return 37; // Space + if (key >= 0x70 && key <= 0x7b) return static_cast(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('0' + id - 1); + if (id >= 11 && id <= 36) return static_cast('A' + id - 11); + if (id == 37) return 0x20; + if (id >= 38 && id <= 49) return static_cast(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& 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 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 " takes. +struct ActionBind { + std::string_view key, name, label, command; +}; +inline constexpr std::array 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 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(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 update(ControllerInput input) noexcept { - if (!input.available || input.device != device) { - chord = 0; ready = false; device = input.device; + std::array keyboard_chord{}; + std::optional 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; diff --git a/Engine/Game/Multiplayer/object_placement.h b/Engine/Game/Multiplayer/object_placement.h index e53010a5..443b9b1b 100644 --- a/Engine/Game/Multiplayer/object_placement.h +++ b/Engine/Game/Multiplayer/object_placement.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -27,4 +28,21 @@ inline std::optional 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 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 diff --git a/Engine/Game/Multiplayer/role_commands.h b/Engine/Game/Multiplayer/role_commands.h new file mode 100644 index 00000000..1da9541f --- /dev/null +++ b/Engine/Game/Multiplayer/role_commands.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +// 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 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 diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 1e24eae1..f135e136 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -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 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 answers; + std::vector 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 lines; std::vector commands; // what "/" offers in this session std::vector 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 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 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 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 diff --git a/Engine/Game/Multiplayer/tick_settings.h b/Engine/Game/Multiplayer/tick_settings.h index 3f534404..43fa9b37 100644 --- a/Engine/Game/Multiplayer/tick_settings.h +++ b/Engine/Game/Multiplayer/tick_settings.h @@ -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 diff --git a/Engine/Game/Rendering/Test/draw_packet_tests.cpp b/Engine/Game/Rendering/Test/draw_packet_tests.cpp new file mode 100644 index 00000000..aff18a79 --- /dev/null +++ b/Engine/Game/Rendering/Test/draw_packet_tests.cpp @@ -0,0 +1,47 @@ +// A draw packet's bones: unpacked and placed in the world. +#include "Engine/Game/Rendering/draw_packet.h" +#include +#include +#include +#include + +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::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; +} diff --git a/Engine/Game/Rendering/draw_packet.h b/Engine/Game/Rendering/draw_packet.h new file mode 100644 index 00000000..dd063b2b --- /dev/null +++ b/Engine/Game/Rendering/draw_packet.h @@ -0,0 +1,31 @@ +#pragma once +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +// 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; +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& 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; +} +} diff --git a/Engine/Game/Settings/Test/simulation_time_tests.cpp b/Engine/Game/Settings/Test/simulation_time_tests.cpp new file mode 100644 index 00000000..cd483809 --- /dev/null +++ b/Engine/Game/Settings/Test/simulation_time_tests.cpp @@ -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 +#include +#include +#include + +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(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::quiet_NaN()) == 0, "no step, no rate"); + if (failures) return 1; + std::cout << "Simulation time tests passed.\n"; + return 0; +} diff --git a/Engine/Game/Settings/simulation_time.h b/Engine/Game/Settings/simulation_time.h new file mode 100644 index 00000000..4dcc4f76 --- /dev/null +++ b/Engine/Game/Settings/simulation_time.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +// 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(static_cast(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::lround(1.0 / step_seconds)) : 0; +} +} diff --git a/Engine/Game/Skater/Test/first_person_cycle_tests.cpp b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp new file mode 100644 index 00000000..ea2f4748 --- /dev/null +++ b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp @@ -0,0 +1,73 @@ +#include "Engine/Game/Skater/first_person_cycle.h" + +#include +#include + +// 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(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; +} diff --git a/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp b/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp new file mode 100644 index 00000000..393d0f77 --- /dev/null +++ b/Engine/Game/Skater/Test/first_person_stabilize_tests.cpp @@ -0,0 +1,162 @@ +#include "Engine/Game/Skater/first_person_spring.h" + +#include +#include + +// 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, 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(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(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(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(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(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; +} diff --git a/Engine/Game/Skater/Test/velocity_boost_tests.cpp b/Engine/Game/Skater/Test/velocity_boost_tests.cpp new file mode 100644 index 00000000..f9aa04fc --- /dev/null +++ b/Engine/Game/Skater/Test/velocity_boost_tests.cpp @@ -0,0 +1,57 @@ +#include "Engine/Game/Skater/velocity_boost.h" +#include +#include +#include + +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 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(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 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{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{0, 25, 0}, "Up Boost changed in fakie"); + for (const float speed : {0.f, 301.f, std::numeric_limits::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::infinity()}), + "Invalid travel velocity accepted"); + std::cout << "Velocity boost regressions passed.\n"; +} diff --git a/Engine/Game/Skater/first_person_cycle.h b/Engine/Game/Skater/first_person_cycle.h new file mode 100644 index 00000000..8ee30393 --- /dev/null +++ b/Engine/Game/Skater/first_person_cycle.h @@ -0,0 +1,26 @@ +#pragma once +#include + +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}; +} +} diff --git a/Engine/Game/Skater/first_person_spring.h b/Engine/Game/Skater/first_person_spring.h index d20c44af..36a76b3f 100644 --- a/Engine/Game/Skater/first_person_spring.h +++ b/Engine/Game/Skater/first_person_spring.h @@ -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 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); } +inline float euro_alpha(float cutoff, float seconds) { + const float tau = 1 / (2*std::numbers::pi_v*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(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; diff --git a/Engine/Game/Skater/skater_body.h b/Engine/Game/Skater/skater_body.h new file mode 100644 index 00000000..e0e363e2 --- /dev/null +++ b/Engine/Game/Skater/skater_body.h @@ -0,0 +1,56 @@ +#pragma once +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +// 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 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(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 bodies{}; +}; +} diff --git a/Engine/Game/Skater/velocity_boost.h b/Engine/Game/Skater/velocity_boost.h index 7bb2b9b4..00edd6d6 100644 --- a/Engine/Game/Skater/velocity_boost.h +++ b/Engine/Game/Skater/velocity_boost.h @@ -7,7 +7,8 @@ namespace dingosdk { enum class VelocityBoostDirection { forward, up }; inline std::optional> velocity_boost_delta( - const std::array& transform, float speed, VelocityBoostDirection direction) noexcept { + const std::array& transform, float speed, VelocityBoostDirection direction, + const std::array& 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{0, speed, 0}; @@ -17,6 +18,16 @@ inline std::optional> 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; } } diff --git a/Engine/Game/World/Test/park_randomization_tests.cpp b/Engine/Game/World/Test/park_randomization_tests.cpp new file mode 100644 index 00000000..bc55f205 --- /dev/null +++ b/Engine/Game/World/Test/park_randomization_tests.cpp @@ -0,0 +1,56 @@ +#include "Engine/Game/World/park_randomization.h" +#include +#include +#include + +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, 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 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(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; +} diff --git a/Engine/Game/World/custom_level_manifest.cpp b/Engine/Game/World/custom_level_manifest.cpp index fb87f2a2..782b0d24 100644 --- a/Engine/Game/World/custom_level_manifest.cpp +++ b/Engine/Game/World/custom_level_manifest.cpp @@ -86,9 +86,9 @@ void exact_fields(const Json& object, std::initializer_list 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"); diff --git a/Engine/Game/World/custom_level_manifest.h b/Engine/Game/World/custom_level_manifest.h index 759a7377..1ac866b2 100644 --- a/Engine/Game/World/custom_level_manifest.h +++ b/Engine/Game/World/custom_level_manifest.h @@ -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; diff --git a/Engine/Game/World/park_randomization.h b/Engine/Game/World/park_randomization.h new file mode 100644 index 00000000..912f2964 --- /dev/null +++ b/Engine/Game/World/park_randomization.h @@ -0,0 +1,38 @@ +#pragma once +#include "park_rotation.h" +#include +#include + +namespace dingosdk { +// Sample layouts, rather than families, so every authored layout has equal weight. +// Empty lots are deliberately excluded from the random pool. +template 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(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; + } +}; +} diff --git a/Engine/Game/World/park_rotation.h b/Engine/Game/World/park_rotation.h index 54f23c38..357c563a 100644 --- a/Engine/Game/World/park_rotation.h +++ b/Engine/Game/World/park_rotation.h @@ -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; }; diff --git a/Engine/Game/World/world_layers.h b/Engine/Game/World/world_layers.h index d7622055..88886f88 100644 --- a/Engine/Game/World/world_layers.h +++ b/Engine/Game/World/world_layers.h @@ -1,5 +1,6 @@ #pragma once #include "world_layer_catalog.h" +#include #include #include #include @@ -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 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 world_layer_modes{"default", "on", "off"}; // One choice per catalog row (world_layers()), in catalog order. using WorldLayerChoices = std::vector; diff --git a/Engine/Resource/Test/skinned_mesh_tests.cpp b/Engine/Resource/Test/skinned_mesh_tests.cpp new file mode 100644 index 00000000..a74fc002 --- /dev/null +++ b/Engine/Resource/Test/skinned_mesh_tests.cpp @@ -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 +#include +#include +#include + +using namespace dingosdk::frostbite; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +template void put(std::vector& 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& data, std::size_t offset, std::size_t target) { + put(data, offset, target - 16); +} + +// One level of detail, one section of three vertices on palette {5, 7}, one triangle. +struct Made { + std::vector resource, geometry; +}; +Made made_mesh(std::uint8_t second_weight = 55) { + Made m; + auto& r = m.resource; + r.resize(1100); + const std::array header{208, 188, 384}; + for (std::size_t i = 0; i < header.size(); ++i) put(r, i * 4, header[i]); + put(r, 124, 1); + put(r, 180, 1); + pointer(r, 48, 256); + put(r, 256, 1); // kind + put(r, 256 + 8, 1); // one section + pointer(r, 256 + 12, 512); + put(r, 256 + 84, 33); // 16-bit indices + put(r, 256 + 88, 6); // index bytes + put(r, 256 + 92, 108); // vertex bytes: 3 x 36 + put(r, 256 + 116, 0x42); // the chunk's guid + pointer(r, 512 + 16, 1024); // palette + put(r, 512 + 24, 2); + put(r, 512 + 31, 3); // triangle list + put(r, 512 + 32, 1); + put(r, 512 + 44, 3); + const std::size_t d = 512 + 112; + const std::array, 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(r, d + i * 4 + j, elements[i][j]); + put(r, d + 64, 36); + put(r, d + 96, 5); + put(r, d + 97, 1); + put(r, 1024, 5); + put(r, 1026, 7); + auto& g = m.geometry; + for (std::size_t v = 0; v < 3; ++v) { + const auto at = v * 36; + put>(g, at, {static_cast(v), 0.0f, 0.0f}); + put>(g, at + 12, {0, 1, 0, 0}); + put>(g, at + 20, {0, 0, 0, 0}); + put>(g, at + 28, {200, second_weight, 0, 0}); + put>(g, at + 32, {0, 0, 0, 0}); + } + put>(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{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, 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(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> fields) { + auto made = std::make_shared(); + 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>(asset.data)); + check(skeleton.names == std::vector{"Root", "Hand"} && skeleton.parents == std::vector{-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>(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; +} diff --git a/Engine/Resource/binary_bundle.cpp b/Engine/Resource/binary_bundle.cpp index d6a6c8c5..b199ce05 100644 --- a/Engine/Resource/binary_bundle.cpp +++ b/Engine/Resource/binary_bundle.cpp @@ -167,6 +167,8 @@ BinaryBundle read_binary_bundle(const std::span 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 hashes(total); for (auto& hash : hashes) hash = reader.sha1(); diff --git a/Engine/Resource/bundle_ref_table.cpp b/Engine/Resource/bundle_ref_table.cpp index 938a6a57..12353352 100644 --- a/Engine/Resource/bundle_ref_table.cpp +++ b/Engine/Resource/bundle_ref_table.cpp @@ -127,56 +127,89 @@ private: std::unordered_map 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& data, std::vector& 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 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(offset) | 0x800000U | (static_cast(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 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(table.bundles), data.begin() + static_cast(table.lookups), - out.begin() + static_cast(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(offset) | 0x800000U | (static_cast(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(table.payload), data.end(), out.begin() + static_cast(payload)); - put64(out, 8, lookups); - put64(out, 24, bundles); - put32(out, 72, static_cast(rows.size())); - put32(meta, 0, static_cast(payload)); - data = std::move(out); - return holder; -} + // The table as it now is; untouched when nothing was added. + void write(std::vector& data, std::vector& 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 out(payload + 20); + std::copy_n(base_.begin(), table_.bundles, out.begin()); + std::copy(pool_.begin(), pool_.end(), out.begin() + static_cast(table_.bundles)); + std::copy(base_.begin() + static_cast(table_.bundles), base_.begin() + static_cast(table_.lookups), + out.begin() + static_cast(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(table_.payload), base_.end(), out.begin() + static_cast(payload)); + put64(out, 8, lookups); + put64(out, 24, bundles); + put32(out, 72, static_cast(rows_.size())); + put32(meta, 0, static_cast(payload)); + data = std::move(out); + } + +private: + std::span base_; + const Parsed& table_; + std::vector rows_; + std::unordered_set hashes_; + std::unordered_map leaves_; // a leaf name's hash -> the preset that answers to it + std::vector 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 edits) { const std::span 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 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 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; } diff --git a/Engine/Resource/mesh_set.cpp b/Engine/Resource/mesh_set.cpp new file mode 100644 index 00000000..3e1fb9db --- /dev/null +++ b/Engine/Resource/mesh_set.cpp @@ -0,0 +1,208 @@ +#include "mesh_set.h" +#include +#include +#include +#include + +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 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 usage_format{0, 3, 23, 23, 13, 13}; // float3, ushort4, ushort4, ubyte4n, ubyte4n +constexpr std::array 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 data; + template 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(offset); + require(value && value <= data.size() - pointer_base, "a pointer leads outside it"); + return static_cast(value) + pointer_base; + } +}; +struct Attribute { + std::size_t base{}, stride{}, offset{}; + bool present{}; +}; +} + +MeshLod read_mesh_lod(std::span resource, std::size_t lod) { + const Reader r{resource}; + for (std::size_t i = 0; i < known_header.size(); ++i) + require(r.at(i * 4) == known_header[i], "its layout is not the one this reader knows"); + require(r.at(skinned_flag) == 1, "it is not skinned"); + require(lod < r.at(lod_count), "it has no such level of detail"); + const auto at = r.pointer(lod_table + lod * 8); + require(r.at(at + lod_kind) == known_lod_kind, "the level of detail is of another kind"); + require(r.at(at + index_format) == sixteen_bit_indices, "its indices are not 16-bit"); + MeshLod result; + result.chunk = r.at(at + geometry_chunk); + result.vertex_bytes = r.at(at + vertex_bytes); + result.index_bytes = r.at(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 resource) { + const Reader r{resource}; + for (std::size_t i = 0; i < known_header.size(); ++i) + require(r.at(i * 4) == known_header[i], "its layout is not the one this reader knows"); + return r.at(lod_count); +} + +MeshLod mesh_lod_geometry(std::span 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(at + lod_kind) == known_lod_kind, "the level of detail is of another kind"); + MeshLod result; + result.chunk = r.at(at + geometry_chunk); + result.vertex_bytes = r.at(at + vertex_bytes); + result.index_bytes = r.at(at + index_bytes); + require(result.chunk != Guid{} && result.vertex_bytes > 0, "the level of detail is empty"); + return result; +} + +std::vector> read_mesh_positions(std::span resource, std::size_t lod, + std::span 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(at + section_count); + require(sections <= 4096, "it has too many sections"); + const auto table = r.pointer(at + section_table); + std::vector> result; + for (std::uint32_t section = 0; section < sections; ++section) { + const auto s = table + section * section_size; + const std::size_t count = r.at(s + vertex_count); + if (!count) continue; + const auto d = s + declaration; + const auto elements = r.at(d + element_total), streams = r.at(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 bases{}, strides{}; + std::size_t end = r.at(s + vertex_start); + for (std::size_t stream = 0; stream < streams; ++stream) { + strides[stream] = r.at(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(d + element * 4) != position) continue; + const auto stream = r.at(d + element * 4 + 3); + const std::size_t offset = r.at(d + element * 4 + 2); + require(r.at(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>(bases[stream] + vertex * strides[stream] + offset)); + found = true; + } + require(found, "a section has no positions"); + } + return result; +} + +SkinnedMesh read_skinned_mesh(std::span resource, std::size_t lod, std::span 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(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(s + primitive) == triangle_list, "a section is not a triangle list"); + const auto count = r.at(s + vertex_count), triangles = r.at(s + triangle_count); + const auto bones = r.pointer(s + palette); + const auto bone_count = r.at(s + palette_count); + const auto d = s + declaration; + const auto elements = r.at(d + element_total), streams = r.at(d + stream_total); + require(elements <= 16 && streams > 0 && streams <= 16, "a vertex declaration is malformed"); + std::array bases{}, strides{}; + std::size_t end = r.at(s + vertex_start); + for (std::size_t stream = 0; stream < streams; ++stream) { + strides[stream] = r.at(d + stream_table + stream * 2); + require(strides[stream] > 0 && r.at(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 attributes{}; + for (std::size_t element = 0; element < elements; ++element) { + const auto usage = r.at(d + element * 4); + if (usage < position || usage > weights_high) continue; + const auto stream = r.at(d + element * 4 + 3); + const std::size_t offset = r.at(d + element * 4 + 2); + require(r.at(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(result.vertices.size()); + for (std::size_t vertex = 0; vertex < count; ++vertex) { + SkinnedVertex out; + out.position = g.at>(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(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(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(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(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(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; +} +} diff --git a/Engine/Resource/mesh_set.h b/Engine/Resource/mesh_set.h new file mode 100644 index 00000000..4cc2eac0 --- /dev/null +++ b/Engine/Resource/mesh_set.h @@ -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 +#include +#include +#include +#include + +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 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 resource); +[[nodiscard]] MeshLod mesh_lod_geometry(std::span resource, std::size_t lod); +[[nodiscard]] std::vector> read_mesh_positions(std::span resource, std::size_t lod, + std::span geometry); + +struct SkinnedVertex { + std::array position{}; // model space + std::array bones{}; // skeleton bones + std::array weights{}; // summing to 255; 0 for an unused influence +}; +struct SkinnedMesh { + std::vector vertices; + std::vector 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 resource, std::size_t lod, + std::span geometry); +} diff --git a/Engine/Resource/skeleton_asset.cpp b/Engine/Resource/skeleton_asset.cpp new file mode 100644 index 00000000..fd7a5285 --- /dev/null +++ b/Engine/Resource/skeleton_asset.cpp @@ -0,0 +1,36 @@ +#include "skeleton_asset.h" +#include +#include + +namespace dingosdk::frostbite { +namespace { +void require(bool valid, const char* message) { + if (!valid) throw std::runtime_error(std::string("SkeletonAsset: ") + message); +} +template const T& as(const ebx::Value& value, const char* what) { + const auto* result = std::get_if(&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(field(asset, "BoneNames"), "BoneNames is not a list"); + const auto& hierarchy = as(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(names[bone], "a bone name is not text")); + const auto parent = as(hierarchy[bone], "a parent is not a number"); + require(parent >= -1 && parent < static_cast(bone), "a bone's parent does not come before it"); + result.parents.push_back(static_cast(parent)); + } + return result; +} +} diff --git a/Engine/Resource/skeleton_asset.h b/Engine/Resource/skeleton_asset.h new file mode 100644 index 00000000..8c36640a --- /dev/null +++ b/Engine/Resource/skeleton_asset.h @@ -0,0 +1,14 @@ +#pragma once +// Frostbite SkeletonAssets: a skeleton's bones and how they hang together. +#include "ebx_document.h" +#include +#include + +namespace dingosdk::frostbite { +struct Skeleton { + std::vector names; + std::vector 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); +} diff --git a/Engine/Resource/toc.cpp b/Engine/Resource/toc.cpp index d5479caa..93917b47 100644 --- a/Engine/Resource/toc.cpp +++ b/Engine/Resource/toc.cpp @@ -133,6 +133,10 @@ std::string c_string_at(const std::span 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 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 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 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 words(static_cast(dataCount)); reader.seek(chunkDataOffset); + expect_table(reader, static_cast(dataCount), 4, "TOC chunk data count exceeds the file"); + std::vector words(static_cast(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); diff --git a/Engine/Vfs/Test/game_textures_tests.cpp b/Engine/Vfs/Test/game_textures_tests.cpp new file mode 100644 index 00000000..21432e46 --- /dev/null +++ b/Engine/Vfs/Test/game_textures_tests.cpp @@ -0,0 +1,86 @@ +// Game textures read from an installed game: dingosdk_game_textures_tests . +// 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 +#include +#include + +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(region.left * image.width), right = static_cast(region.right * image.width); + const auto top = static_cast(region.top * image.height), bottom = static_cast(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(opaque) / static_cast(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; +} diff --git a/Engine/Vfs/Test/store_copies_tests.cpp b/Engine/Vfs/Test/store_copies_tests.cpp index 47b544d0..fad00c1b 100644 --- a/Engine/Vfs/Test/store_copies_tests.cpp +++ b/Engine/Vfs/Test/store_copies_tests.cpp @@ -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> positions; + for (std::size_t i = 0; i < count; ++i) positions.push_back({first + static_cast(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> positions; + for (std::size_t i = 0; i < count; ++i) positions.push_back({static_cast(i) * 0.01f, 7.0f, 3.5f}); + return mesh_points(positions); + }; + const auto joined = [](std::vector a, const std::vector& 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::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 diff --git a/Engine/Vfs/Test/thunderstore_package_tests.cpp b/Engine/Vfs/Test/thunderstore_package_tests.cpp new file mode 100644 index 00000000..c5081d46 --- /dev/null +++ b/Engine/Vfs/Test/thunderstore_package_tests.cpp @@ -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 +#include + +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/// 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; +} diff --git a/Engine/Vfs/game_archives.cpp b/Engine/Vfs/game_archives.cpp index 0eca63f8..5d5e6ac1 100644 --- a/Engine/Vfs/game_archives.cpp +++ b/Engine/Vfs/game_archives.cpp @@ -1,10 +1,15 @@ #include "game_archives.h" #include "Engine/Resource/cas_codec.h" #include "Engine/Core/Platform/path_text.h" +#include #include #include #include +#include #include +#ifdef _WIN32 +#include +#endif namespace dingosdk::vfs { namespace fs = std::filesystem; @@ -40,6 +45,29 @@ std::vector GameArchives::chunks_in(const std::string& directory) return chunks; } +#ifdef _WIN32 +struct GameArchives::Open { + std::mutex mutex; + std::map 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(); } + + GameArchives::GameArchives(fs::path root, const native_db::Node& layout) : root_(std::move(root)) { std::map, std::vector> byArchives; std::error_code error; @@ -108,6 +136,38 @@ std::vector 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 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(offset)); @@ -151,8 +211,10 @@ const std::string& GameArchives::directory(std::uint32_t installChunk) const { std::optional 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(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; } } diff --git a/Engine/Vfs/game_archives.h b/Engine/Vfs/game_archives.h index e0e6e9c4..cdd6b330 100644 --- a/Engine/Vfs/game_archives.h +++ b/Engine/Vfs/game_archives.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -63,8 +64,17 @@ public: [[nodiscard]] const std::filesystem::path& root() const noexcept { return root_; } static std::optional 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_; std::filesystem::path root_; std::map> directories_; std::map chunkDirectory_; diff --git a/Engine/Vfs/game_textures.cpp b/Engine/Vfs/game_textures.cpp new file mode 100644 index 00000000..7bf4285d --- /dev/null +++ b/Engine/Vfs/game_textures.cpp @@ -0,0 +1,57 @@ +#include "game_textures.h" +#include +#include + +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 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(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(1, image.width * side / fitted), + std::max(1, image.height * side / fitted)); +} +} diff --git a/Engine/Vfs/game_textures.h b/Engine/Vfs/game_textures.h new file mode 100644 index 00000000..f4d1d5f2 --- /dev/null +++ b/Engine/Vfs/game_textures.h @@ -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 +#include +#include +#include +#include +#include + +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> tocs_; + std::map, GameBundle> bundles_; +}; +} diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index d655c721..4ea1975a 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -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 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(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()); diff --git a/Engine/Vfs/https_download.h b/Engine/Vfs/https_download.h index 91b777ae..1b9073f2 100644 --- a/Engine/Vfs/https_download.h +++ b/Engine/Vfs/https_download.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -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 received{}, total{}; + std::atomic 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 get_text(std::wstring_view url, std::uint64_t max_bytes, diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index ff670f7f..a6b1500f 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -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 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(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 text{}; + const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast(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::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 set_aside; +}; +std::mutex board_break_mutex; +std::string chosen_deck; +std::optional 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 board_break_folders() noexcept { + std::vector found; + try { + std::vector> 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 installed_board_break_decks() noexcept { + std::vector 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 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 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(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); diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 493bd05f..add4faa9 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -3,9 +3,11 @@ #include "mod_list.h" #include "mod_merge.h" +#include #include #include #include +#include #include 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 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 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_ (also behind a team name, as a mod manager installs it: +// Team-Board_Break_). 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 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 installed_board_break_decks() noexcept; +std::vector 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 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; diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 605fc938..710d1e37 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -6,14 +6,23 @@ #include "mod_store_copies.h" #include "native_db.h" +#include + #include #include +#include +#include +#include #include +#include +#include #include #include +#include #include #include #include +#include namespace dingosdk::mods { using namespace detail; @@ -38,12 +47,22 @@ constexpr std::array 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& checked, + std::size_t threads, bool background) { + // (Only when some are skipped is the catalogue copied, without them.) + std::optional 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 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> 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(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{}, + 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 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::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 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> held; + std::exception_ptr failure; + bool done{}; + }; + std::vector 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 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 nextJob{0}; + std::atomic stopJobs{false}; + std::vector 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& flag; + ~Stop() { flag = true; } + } stopOnExit{stopJobs}; + const std::size_t wantedWorkers = jobs.size() < 2 ? 0 + : std::min({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 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(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(); diff --git a/Engine/Vfs/mod_merge.h b/Engine/Vfs/mod_merge.h index 97e4e9ca..5d516a2b 100644 --- a/Engine/Vfs/mod_merge.h +++ b/Engine/Vfs/mod_merge.h @@ -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 checked; }; // Combines every enabled mod into one patch under Mods/.reskate: superbundle diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index 0bbf7ebc..c7e02288 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -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 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 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(encoded.data()), static_cast(encoded.size()))) throw std::runtime_error("Cannot append to " + path_utf8(path)); - fb::BundleFileInfo info{{true, installChunk, archive}, static_cast(offset), - static_cast(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 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::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(held.size()), + static_cast(encoded.size())}; + held.insert(held.end(), encoded.begin(), encoded.end()); + return info; + } + const auto start = append(path, encoded); + return {{true, installChunk, archive}, static_cast(start), static_cast(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::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(moved); + return true; } void CasStore::shift(fb::CasIdentifier& location, std::uint32_t& offset, diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index dc6b4cf4..0ff70ca9 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include @@ -106,13 +108,15 @@ struct BundleState { std::size_t gameMetadata{ChunkRecord::none}; std::vector shippedChunks; // Keyed position of each asset, so replacing one stays constant time: these - // bundles hold tens of thousands of entries. - std::map> 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 seen; // Every copy of an asset more than one source supplies, base included. - std::map, std::less<>> history; + std::unordered_map> 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> baseSha; + std::unordered_map 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> 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, fb::BundleFileInfo> carriedAt; + // The names any mod changed, to rule the rest out without a copy of each (name_hash). + std::unordered_set 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& assets, const auto& changes, std::size_t first) { + const auto replace = [&](std::vector& assets, const auto& changes, + const std::unordered_set& 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 " diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index a169b9ee..591b5028 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -40,7 +40,7 @@ void write_file(const fs::path& path, std::span bytes) { { std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output || !output.write(reinterpret_cast(bytes.data()), - static_cast(bytes.size()))) + static_cast(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)) { diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index 722ae95b..b2cb4e3b 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -26,6 +26,16 @@ namespace fb = frostbite; std::string lower(std::string_view text); std::vector 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(c + ('a' - 'A')); + value = (value ^ static_cast(c)) * 0x100000001B3ULL; + } + return value; +} void write_file(const fs::path& path, std::span 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 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 encoded); + [[nodiscard]] fs::path archive_path(std::uint32_t installChunk, std::uint16_t archive) const; + fs::path output_; std::map 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 bytes; + std::uint64_t at{}; + }; + bool waiting_{}; + std::optional archive_; + std::map held_; }; using ArchiveUse = std::set>; @@ -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& mods, const std::map& 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 diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 049e7c73..295576ad 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -4,152 +4,289 @@ #include "Engine/Resource/ebx_merge.h" #include "Engine/Resource/ebx_writer.h" +#include + #include #include +#include +#include +#include +#include #include #include +#include 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; + // The game's copy of each asset: its sha1, and where in the listing it is. + std::map, std::less<>> original; + std::map> scripts; + std::set> resources; +}; +struct GameToc { + fb::TocDocument document; + std::map> bundles; + std::set 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 Shared { +public: + template 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> 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 encoded; + std::optional 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> candidates, companions; + std::vector unreadable; // per candidate: never follows + std::map, std::less<>> addedIn; // the documents it added to each bundle, by guid + std::vector newChunks; // TOC chunks the game's TOCs do not have + std::set imported; + std::vector changes; + std::vector