mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 13:57:05 +02:00
Merge branch 'main' into true-first-person
This commit is contained in:
283 files changed
+26905
-1608
No files matched your search
+49
-5
@@ -27,6 +27,7 @@ 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 AND WIN32)
|
||||
enable_testing()
|
||||
add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp)
|
||||
@@ -66,6 +67,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
|
||||
Extension/Throwdowns/throwdown_wire.cpp)
|
||||
target_include_directories(dingosdk_server_activity_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME server_activity COMMAND dingosdk_server_activity_tests)
|
||||
add_executable(dingosdk_server_text_tests Server/Test/server_text_tests.cpp)
|
||||
target_include_directories(dingosdk_server_text_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME server_text COMMAND dingosdk_server_text_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)
|
||||
@@ -80,6 +84,15 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
|
||||
dingosdk_lz4 dingosdk_zstd)
|
||||
endif()
|
||||
add_test(NAME server_config COMMAND dingosdk_server_config_tests)
|
||||
add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp)
|
||||
target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json)
|
||||
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)
|
||||
target_include_directories(dingosdk_global_bans_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json $<$<BOOL:${WIN32}>:dingosdk_https>)
|
||||
add_test(NAME server_global_bans COMMAND dingosdk_global_bans_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)
|
||||
@@ -88,6 +101,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)
|
||||
@@ -96,6 +115,8 @@ 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}")
|
||||
@@ -132,8 +153,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)
|
||||
@@ -151,11 +172,16 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
|
||||
add_executable(dingosdk_multiplayer_mesh_tests
|
||||
Extension/Multiplayer/Test/mesh_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp
|
||||
Extension/Multiplayer/Session/password.cpp Extension/Multiplayer/Steam/steam_lobbies.cpp
|
||||
Extension/Multiplayer/Steam/steam_server_browser.cpp)
|
||||
Extension/Multiplayer/Session/password.cpp Extension/Multiplayer/Session/party_book.cpp
|
||||
Extension/Multiplayer/Steam/steam_lobbies.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_mesh_tests PRIVATE dingosdk_logging dingosdk_launcher_support dingosdk_lz4 dingosdk_zstd bcrypt
|
||||
dingosdk_word_filter)
|
||||
add_test(NAME multiplayer_mesh COMMAND dingosdk_multiplayer_mesh_tests)
|
||||
add_executable(dingosdk_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)
|
||||
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)
|
||||
@@ -185,10 +211,28 @@ if(WIN32)
|
||||
file(GLOB_RECURSE game_headers CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Game/*.h")
|
||||
target_sources(dingosdk_runtime PRIVATE ${game_headers})
|
||||
endif()
|
||||
if(DINGOSDK_BUILD_TRAINER_TESTS)
|
||||
enable_testing()
|
||||
add_executable(dingosdk_trainer_tuning_dump Extension/Trainer/Test/tuning_dump.cpp Extension/Skater/physics_tuning_model.cpp)
|
||||
target_include_directories(dingosdk_trainer_tuning_dump PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_trainer_tuning_dump PRIVATE dingosdk_game_archives)
|
||||
# Fails when the game's data stops naming a tuning value (the trainer's table comes from those names).
|
||||
add_test(NAME trainer_tuning_names COMMAND dingosdk_trainer_tuning_dump "${DINGOSDK_TEST_GAME_ROOT}")
|
||||
# What a session's host shares beyond its tuning: the encoding, and what a guest refuses.
|
||||
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()
|
||||
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
|
||||
foreach(target IN LISTS project_targets)
|
||||
if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz)$")
|
||||
dingosdk_configure_target(${target})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
+16
-9
@@ -8,6 +8,7 @@
|
||||
//
|
||||
// Dropping a folder on the exe writes the pack next to it, in "<folder>_pack".
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include <Windows.h>
|
||||
#include <wincodec.h>
|
||||
#include <wrl/client.h>
|
||||
@@ -26,6 +27,7 @@
|
||||
using Microsoft::WRL::ComPtr;
|
||||
namespace fs = std::filesystem;
|
||||
using dingosdk::Json;
|
||||
using dingosdk::path_utf8;
|
||||
|
||||
namespace {
|
||||
// ReSkate's loader limits (chat_emotes.cpp): frames at most 256 x 256, names of letters,
|
||||
@@ -198,10 +200,12 @@ std::vector<Frame> tidy(std::vector<Frame> frames, std::size_t limit) {
|
||||
return thinned;
|
||||
}
|
||||
|
||||
// Built from the wide name: path::string() throws on a character outside the ANSI code page,
|
||||
// and one such file would stop the whole pack.
|
||||
std::string emote_name(const fs::path &file) {
|
||||
std::string name;
|
||||
for (const char c : file.stem().string())
|
||||
name += std::isalnum(static_cast<unsigned char>(c)) ? c : '_';
|
||||
for (const wchar_t c : file.stem().wstring())
|
||||
name += c < 128 && std::isalnum(static_cast<unsigned char>(c)) ? static_cast<char>(c) : '_';
|
||||
if (name.size() > max_name) name.resize(max_name);
|
||||
return name.empty() ? std::string("emote") : name;
|
||||
}
|
||||
@@ -266,7 +270,7 @@ Options parse(int argc, wchar_t **argv) {
|
||||
if (arg == L"--size") o.size = std::stoi(value());
|
||||
else if (arg == L"--max-frames") o.max_frames = static_cast<std::size_t>(std::stoul(value()));
|
||||
else if (arg == L"--max-width") o.max_width = std::stoi(value());
|
||||
else if (arg == L"--name") o.name = fs::path(value()).string();
|
||||
else if (arg == L"--name") o.name = path_utf8(value());
|
||||
else if (arg == L"--help" || arg == L"-h" || arg == L"/?") throw std::invalid_argument("");
|
||||
else positional.emplace_back(arg);
|
||||
}
|
||||
@@ -281,16 +285,16 @@ Options parse(int argc, wchar_t **argv) {
|
||||
}
|
||||
|
||||
int run(const Options &o) {
|
||||
if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + o.input.string());
|
||||
if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + path_utf8(o.input));
|
||||
std::vector<fs::path> files;
|
||||
for (const auto &entry : fs::directory_iterator(o.input)) {
|
||||
const auto extension = lowered(entry.path().extension().string());
|
||||
const auto extension = lowered(path_utf8(entry.path().extension()));
|
||||
if (entry.is_regular_file() && (extension == ".png" || extension == ".gif" || extension == ".jpg" ||
|
||||
extension == ".jpeg" || extension == ".bmp" || extension == ".webp"))
|
||||
files.push_back(entry.path());
|
||||
}
|
||||
std::sort(files.begin(), files.end());
|
||||
if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + o.input.string());
|
||||
if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + path_utf8(o.input));
|
||||
|
||||
std::vector<Emote> emotes;
|
||||
std::set<std::string> names, folded;
|
||||
@@ -298,6 +302,8 @@ int run(const Options &o) {
|
||||
for (const auto &file : files) {
|
||||
try {
|
||||
auto name = emote_name(file);
|
||||
if (std::none_of(name.begin(), name.end(), [](unsigned char c) { return std::isalnum(c); }))
|
||||
throw std::runtime_error("no letters or digits to name it by; rename the file");
|
||||
for (int n = 2; names.contains(name); ++n) {
|
||||
const auto suffix = "_" + std::to_string(n);
|
||||
name = emote_name(file).substr(0, max_name - suffix.size()) + suffix;
|
||||
@@ -315,10 +321,10 @@ int run(const Options &o) {
|
||||
names.insert(name);
|
||||
std::printf(" :%s: %dx%d%s\n", name.c_str(), frames.front().image.width, frames.front().image.height,
|
||||
frames.size() > 1 ? (", " + std::to_string(frames.size()) + " frames").c_str() : "");
|
||||
emotes.push_back({name, file.filename().string(), std::move(frames)});
|
||||
emotes.push_back({name, path_utf8(file.filename()), std::move(frames)});
|
||||
} catch (const std::exception &e) {
|
||||
++failed;
|
||||
std::printf(" skipped %s: %s\n", file.filename().string().c_str(), e.what());
|
||||
std::printf(" skipped %s: %s\n", path_utf8(file.filename()).c_str(), e.what());
|
||||
}
|
||||
}
|
||||
if (emotes.empty()) throw std::runtime_error("no pictures could be read");
|
||||
@@ -367,13 +373,14 @@ int run(const Options &o) {
|
||||
std::printf("\n%zu emotes (%zu frames) in a %dx%d atlas%s.\nWrote %s\\emotes.json and emotes.png.\n"
|
||||
"Copy both into the repository's assets\\emotes and rebuild ReSkate.dll.\n",
|
||||
emotes.size(), total, o.max_width, height, failed ? (", " + std::to_string(failed) + " skipped").c_str() : "",
|
||||
o.output.string().c_str());
|
||||
path_utf8(o.output).c_str());
|
||||
return failed ? 2 : 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int wmain(int argc, wchar_t **argv) {
|
||||
std::setvbuf(stdout, nullptr, _IONBF, 0); // progress shows as it happens, even through a pipe
|
||||
SetConsoleOutputCP(CP_UTF8); // file names are printed as UTF-8
|
||||
const bool own_console = [] {
|
||||
DWORD processes[2];
|
||||
return GetConsoleProcessList(processes, 2) == 1; // started by double-click or drag-and-drop
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#include "launcher_support.h"
|
||||
#include "launcher_support_internal.h"
|
||||
#include "path_text.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <Windows.h>
|
||||
#include <bcrypt.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <fstream>
|
||||
#include <openssl/sha.h>
|
||||
#endif
|
||||
@@ -137,15 +139,56 @@ std::string rva_string(const MappedFile& file, const ParsedPe& pe, std::uint32_t
|
||||
fail("Unterminated PE export name");
|
||||
}
|
||||
|
||||
// Opens a file to read all of it. A file that was written a moment ago (a download) is often
|
||||
// held by the antivirus scanning it, so a sharing violation is waited out for a few seconds.
|
||||
// A file that still cannot be opened is named along with the reason: with ReSkate.dll that is
|
||||
// nearly always an antivirus blocking it, and "cannot open file" told the player nothing.
|
||||
Handle open_to_read(const fs::path& path) {
|
||||
DWORD error{};
|
||||
for (int attempt = 0; attempt < 50; ++attempt) {
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr));
|
||||
if (file.get() != INVALID_HANDLE_VALUE) return file;
|
||||
error = GetLastError();
|
||||
if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) break;
|
||||
Sleep(100);
|
||||
}
|
||||
const auto name = path_utf8(path.filename());
|
||||
const auto folder = path_utf8(path.parent_path());
|
||||
std::string message;
|
||||
switch (error) {
|
||||
case ERROR_VIRUS_INFECTED:
|
||||
case ERROR_VIRUS_DELETED:
|
||||
message = "Your antivirus blocked " + name + ". Add the folder " + folder +
|
||||
" to its exclusions (in Windows Security: Virus & threat protection > Exclusions), then try again.";
|
||||
break;
|
||||
case ERROR_ACCESS_DENIED:
|
||||
message = name + " cannot be read: access was denied. An antivirus is probably blocking it: add the folder " + folder +
|
||||
" to its exclusions, then try again.";
|
||||
break;
|
||||
case ERROR_SHARING_VIOLATION:
|
||||
case ERROR_LOCK_VIOLATION:
|
||||
message = name + " is in use by another program, usually an antivirus scan. Close Skate if it is running, wait a moment and try again; "
|
||||
"if it keeps happening, add the folder " + folder + " to your antivirus exclusions.";
|
||||
break;
|
||||
case ERROR_FILE_NOT_FOUND:
|
||||
case ERROR_PATH_NOT_FOUND:
|
||||
message = name + " is missing from " + folder + ". If it was just downloaded, an antivirus removed it: add the folder to its "
|
||||
"exclusions, then try again.";
|
||||
break;
|
||||
default:
|
||||
message = "Cannot read " + name + " in " + folder;
|
||||
break;
|
||||
}
|
||||
throw std::runtime_error(message + " (Windows error " + std::to_string(error) + ")");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
std::string sha256_file(const fs::path& path) {
|
||||
#ifdef _WIN32
|
||||
Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr));
|
||||
if (file.get() == INVALID_HANDLE_VALUE) detail::fail("Cannot open file for SHA-256");
|
||||
const auto file = open_to_read(path);
|
||||
|
||||
Algorithm algorithm;
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm.value, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0)
|
||||
@@ -179,8 +222,15 @@ std::string sha256_file(const fs::path& path) {
|
||||
}
|
||||
return output;
|
||||
#else
|
||||
// Name the file and the reason, as open_to_read does on Windows: a server admin reading
|
||||
// "cannot open file" in the log has no idea which file, or what to fix.
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
if (!input) detail::fail("Cannot open file for SHA-256");
|
||||
if (!input) {
|
||||
const int error = errno;
|
||||
throw std::runtime_error("Cannot open " + path_utf8(path.filename()) + " in " +
|
||||
path_utf8(path.parent_path()) + " for SHA-256: " +
|
||||
(error ? std::strerror(error) : "unknown error"));
|
||||
}
|
||||
SHA256_CTX context;
|
||||
if (!SHA256_Init(&context)) detail::fail("Cannot initialize SHA-256");
|
||||
std::vector<unsigned char> bytes(1024 * 1024);
|
||||
|
||||
@@ -40,8 +40,10 @@ inline std::ptrdiff_t peek_cstring(std::uintptr_t address, char* destination, st
|
||||
if (!destination || !capacity || address < 0x10000 || address > highest_user_address - capacity) return -1;
|
||||
__try {
|
||||
const auto* source = reinterpret_cast<const char*>(address);
|
||||
for (std::size_t i = 0; i < capacity; ++i)
|
||||
if (!(destination[i] = source[i])) return static_cast<std::ptrdiff_t>(i);
|
||||
for (std::size_t i = 0; i < capacity; ++i) {
|
||||
destination[i] = source[i];
|
||||
if (destination[i] == '\0') return static_cast<std::ptrdiff_t>(i);
|
||||
}
|
||||
return -1;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return -1;
|
||||
|
||||
@@ -186,6 +186,7 @@ const HANDLE symbol_session = reinterpret_cast<HANDLE>(static_cast<std::uintptr_
|
||||
bool symbols_ready{};
|
||||
std::unordered_set<std::uintptr_t> symbols_loaded;
|
||||
|
||||
const char waiting_marker{}; // an address inside this module, to tell it from the others
|
||||
std::string symbol_name(const Module& module, std::uintptr_t address) {
|
||||
std::lock_guard lock(symbols_mutex);
|
||||
if (!symbols_ready) {
|
||||
@@ -200,8 +201,16 @@ std::string symbol_name(const Module& module, std::uintptr_t address) {
|
||||
info->SizeOfStruct = sizeof(SYMBOL_INFO);
|
||||
info->MaxNameLen = 511;
|
||||
DWORD64 displacement{};
|
||||
if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0])
|
||||
return module.name + "!" + info->Name;
|
||||
if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0]) {
|
||||
// A release ReSkate.dll ships without its symbols, so all this finds in it is the nearest
|
||||
// export, megabytes away: every function of ours then reads as one of three exports and
|
||||
// a report cannot say which was slow. Unless the address is inside a symbol with a size
|
||||
// (the real ones have one), give our own module's address instead: the release's symbols
|
||||
// (build/release/ReSkate-<version>-symbols) name it afterwards.
|
||||
const auto here = reinterpret_cast<std::uintptr_t>(&waiting_marker);
|
||||
const bool own = module.base <= here && here < module.end;
|
||||
if (!own || (info->Size && displacement < info->Size)) return module.name + "!" + info->Name;
|
||||
}
|
||||
return std::format("{}+0x{:x}", module.name, address - module.base);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include "engine.h"
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::local_music {
|
||||
@@ -40,6 +41,11 @@ 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}};
|
||||
// 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}};
|
||||
inline constexpr std::array<Fingerprint, 7> music_ui_contracts{{
|
||||
music_ui_initialize_contract,
|
||||
{construct_playlist, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x21,0x95,0x32,0x04,0xe8,0x94,0x30,0x5e,0x00,0x33,0xc9,0x48,0x8d}},
|
||||
@@ -49,4 +55,20 @@ inline constexpr std::array<Fingerprint, 7> music_ui_contracts{{
|
||||
{context_insert, {0x40,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x38,0x49,0x8b,0xf9,0x49,0x8b,0xd8,0x48,0x8b,0xf2,0x4c,0x8b,0xf9,0x33,0xed,0x48}},
|
||||
{complete_delegate, {0x48,0x83,0xec,0x28,0x48,0x8b,0x11,0x48,0x8b,0x0a,0x48,0x8b,0x41,0x40,0x48,0x85,0xc0,0x74,0x0d,0x80,0x79,0x68,0x00,0x75,0x07,0xff,0xd0,0x48,0x83,0xc4,0x28,0xc3}}
|
||||
}};
|
||||
// Playlist lookup crash guard. The shipped loop loads a song entry (rcx), masks it with
|
||||
// `and rcx, -5` and tests a flag at [rcx+0x14]; an unresolved or NULL entry makes that
|
||||
// dereference fault and crashes the game. The 13-byte patch replaces the leading 5-byte
|
||||
// `mov eax, 0` with a 2-byte `xor eax, eax`, a 2-byte `jrcxz` that jumps past the whole
|
||||
// dereference when rcx is zero, and a padding `nop`, so the untouched tail stays aligned.
|
||||
inline constexpr std::uintptr_t playlist_lookup_safety_rva = 0x14f80c7;
|
||||
inline constexpr std::array<unsigned char, 13> playlist_lookup_safety_contract{
|
||||
0xb8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x83, 0xe1, 0xfb, 0x66, 0x85, 0x71, 0x14};
|
||||
inline constexpr std::array<unsigned char, 13> playlist_lookup_patch_bytes{
|
||||
0x31, 0xc0, 0x48, 0x83, 0xe1, 0xfb, 0xe3, 0x29, 0x66, 0x85, 0x71, 0x14, 0x90};
|
||||
// The jrcxz sits at +6 (after the 2-byte xor and 4-byte and) and ends at +8; its +0x29
|
||||
// displacement lands on the loop increment (`inc r9d; add r8, 4`) instead of the fault.
|
||||
inline constexpr std::size_t playlist_lookup_jump_next = 8;
|
||||
inline constexpr std::size_t playlist_lookup_jump_displacement = 0x29;
|
||||
inline constexpr std::uintptr_t playlist_lookup_jump_target =
|
||||
playlist_lookup_safety_rva + playlist_lookup_jump_next + playlist_lookup_jump_displacement;
|
||||
}
|
||||
@@ -35,4 +35,16 @@ inline constexpr std::array<OffboardFlightState, 3> offboard_flight_states{{
|
||||
0xe3,0x71,0x21,0x4f,0x18,0x20,0xc5,0xe8,0x58,0xc1,0xc5,0xf8,0x11,0x06,0x4c,0x8b}}},
|
||||
}};
|
||||
inline constexpr std::uintptr_t offboard_flight_vtable = 0x65e8f40;
|
||||
// FollowRagdoll / FollowAnimatedRagdoll / FollowSimulatedRagdoll also keep
|
||||
// world velocity at +10. Their shared update derives it from displacement
|
||||
// (RVA 48380DE), including Y at +14. Boosts must update this cached velocity
|
||||
// as well as the skeleton bodies when diving or gliding.
|
||||
inline constexpr std::array<std::array<std::uintptr_t, 3>, 3> offboard_ragdoll_states{{
|
||||
{0x720, 0x65e8df8, 0x4832c80},
|
||||
{0x8e0, 0x65e8e60, 0x4830a70},
|
||||
{0xaa0, 0x65e8ed0, 0x48332f0},
|
||||
}};
|
||||
inline constexpr Fingerprint ragdoll_velocity_write{0x48380de, {
|
||||
0xc4,0xe2,0x79,0x18,0x80,0xec,0x17,0x00,0x00,0xc5,0xe8,0x5e,0xc8,0xc5,0xf8,0x11,
|
||||
0x4f,0x10,0xc5,0xf8,0x10,0x43,0x30,0xc5,0xf8,0x11,0x87,0x60,0x01,0x00,0x00,0x4c}};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Reviewed native contract for supported_build::game_sha256 (September 29 2026).
|
||||
// fb::FBPhysicsWorld creation copies the 0xa8-byte FBPhysicsWorldSize at the
|
||||
// front of its descriptor into the job arguments (+0x2428) before any pool,
|
||||
// free list or per-body array is sized from it. Both native callers build that
|
||||
// descriptor on their own stack immediately before the call.
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::physics_world_size {
|
||||
template<std::size_t N> consteval auto hex_bytes(const char (&hex)[N]) {
|
||||
static_assert(N % 2 == 1);
|
||||
std::array<std::uint8_t, (N - 1) / 2> result{};
|
||||
const auto nibble = [](char c) -> unsigned {
|
||||
if (c >= '0' && c <= '9') return static_cast<unsigned>(c - '0');
|
||||
if (c >= 'a' && c <= 'f') return static_cast<unsigned>(c - 'a' + 10);
|
||||
throw "Invalid physics world size contract hex";
|
||||
};
|
||||
for (std::size_t i = 0; i < result.size(); ++i)
|
||||
result[i] = static_cast<std::uint8_t>((nibble(hex[i * 2]) << 4) | nibble(hex[i * 2 + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
// World creation entry, through the stack cookie.
|
||||
inline constexpr auto create_entry = hex_bytes(
|
||||
"48895c241848897424205741544155415641574881eca0030000c5f829b42490030000488b05a6d3bb044833c4"
|
||||
"4889842480030000");
|
||||
// rbx = this->m_jobArguments; copy descriptor[0x00..0xa8) to rbx+0x2428.
|
||||
inline constexpr auto size_copy = hex_bytes(
|
||||
"488b5e28c4c17c1006c5fc118328240000c4c17c104e20c5fc118b48240000c4c17c104640c5fc118368240000"
|
||||
"c4c17c104e60c5fc118b88240000c4c17c108680000000c5fc1183a8240000c4c17b108ea0000000c5fb118bc8"
|
||||
"240000");
|
||||
// The world reserves one internal body on top of BodyCount (data 8200 -> 8201 slots).
|
||||
inline constexpr auto internal_body = hex_bytes("ff833024000083832824000003");
|
||||
// Both callers: lea rdx,[stack descriptor]; mov rcx,rax; call create.
|
||||
inline constexpr auto caller_a_call = hex_bytes("e851195200");
|
||||
inline constexpr auto caller_b_call = hex_bytes("e8306f8dfe");
|
||||
|
||||
struct Contract {
|
||||
std::uintptr_t rva;
|
||||
std::span<const std::uint8_t> bytes;
|
||||
};
|
||||
inline constexpr Contract contracts[]{
|
||||
{0x02606ff0, create_entry},
|
||||
{0x02607fec, size_copy},
|
||||
{0x026080b0, internal_body},
|
||||
{0x020e569a, caller_a_call},
|
||||
{0x03d300bb, caller_b_call},
|
||||
};
|
||||
inline constexpr std::size_t max_contract_size = [] {
|
||||
std::size_t size = 0;
|
||||
for (const auto& contract : contracts) size = contract.bytes.size() > size ? contract.bytes.size() : size;
|
||||
return size;
|
||||
}();
|
||||
|
||||
// unsigned-int fields of FBPhysicsWorldSize in reflected field order (index * 4).
|
||||
inline constexpr std::size_t size_bytes = 0xa8;
|
||||
inline constexpr std::size_t geometry_count = 0x00;
|
||||
inline constexpr std::size_t owner_count = 0x04;
|
||||
inline constexpr std::size_t body_count = 0x08;
|
||||
inline constexpr std::size_t hull_and_mesh_count = 0x14;
|
||||
inline constexpr std::size_t shape_count = 0x18;
|
||||
inline constexpr std::size_t aggregate_shape_count = 0x1c;
|
||||
inline constexpr std::size_t fixed_shape_count = 0x20;
|
||||
inline constexpr std::size_t fixed_partition_count = 0x3c;
|
||||
inline constexpr std::size_t persistent_spatial_index_count = 0xa0;
|
||||
|
||||
// fb::FBPhysicsWorld creation: (void* world, Descriptor* descriptor).
|
||||
inline constexpr std::uintptr_t create_rva = 0x02606ff0;
|
||||
} // namespace dingosdk::game::build::v20260929::physics_world_size
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -7,10 +9,69 @@
|
||||
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;
|
||||
inline constexpr std::uint8_t keyboard_key_id(std::uint32_t key) noexcept {
|
||||
if (key >= '0' && key <= '9') return static_cast<std::uint8_t>(key - '0' + 1);
|
||||
if (key >= 'A' && key <= 'Z') return static_cast<std::uint8_t>(key - 'A' + 11);
|
||||
if (key == 0x20) return 37; // Space
|
||||
if (key >= 0x70 && key <= 0x7b) return static_cast<std::uint8_t>(key - 0x70 + 38); // F1-F12
|
||||
if (key == 0x11) return 50; // Ctrl
|
||||
if (key == 0x10) return 51; // Shift
|
||||
if (key == 0x12) return 52; // Alt
|
||||
return 0;
|
||||
}
|
||||
inline constexpr std::uint8_t keyboard_key_from_id(std::uint32_t id) noexcept {
|
||||
if (id >= 1 && id <= 10) return static_cast<std::uint8_t>('0' + id - 1);
|
||||
if (id >= 11 && id <= 36) return static_cast<std::uint8_t>('A' + id - 11);
|
||||
if (id == 37) return 0x20;
|
||||
if (id >= 38 && id <= 49) return static_cast<std::uint8_t>(0x70 + id - 38);
|
||||
if (id == 50) return 0x11;
|
||||
if (id == 51) return 0x10;
|
||||
if (id == 52) return 0x12;
|
||||
return 0;
|
||||
}
|
||||
inline constexpr bool bindable_keyboard_key(std::uint32_t key) noexcept {
|
||||
return keyboard_key_id(key) != 0;
|
||||
}
|
||||
inline constexpr bool keyboard_binding(std::uint32_t value) noexcept {
|
||||
if ((value & ~0xffu) == keyboard_binding_tag) return bindable_keyboard_key(value & 0xffu);
|
||||
if ((value & 0xc0000000u) != keyboard_chord_tag) return false;
|
||||
unsigned count = 0, previous = 0;
|
||||
bool ended = false;
|
||||
for (unsigned slot = 0; slot < 5; ++slot) {
|
||||
const unsigned id = (value >> (slot * 6)) & 0x3fu;
|
||||
if (!id) { ended = true; continue; }
|
||||
if (ended || !keyboard_key_from_id(id) || id <= previous) return false;
|
||||
previous = id;
|
||||
++count;
|
||||
}
|
||||
return count >= 2;
|
||||
}
|
||||
inline std::uint32_t keyboard_binding_from_keys(const std::array<std::uint64_t, 4>& keys) noexcept {
|
||||
std::uint32_t value = keyboard_chord_tag;
|
||||
unsigned count = 0;
|
||||
for (unsigned id = 1; id <= 52; ++id) {
|
||||
const auto key = keyboard_key_from_id(id);
|
||||
if (!(keys[key / 64] & (std::uint64_t{1} << (key % 64)))) continue;
|
||||
if (count == 5) return 0;
|
||||
value |= id << (count++ * 6);
|
||||
}
|
||||
if (count == 1) {
|
||||
const auto id = value & 0x3fu;
|
||||
return keyboard_binding_tag | keyboard_key_from_id(id);
|
||||
}
|
||||
return count >= 2 ? value : 0;
|
||||
}
|
||||
inline constexpr bool valid_controller_combo(std::uint32_t value) noexcept {
|
||||
return (value & ~controller_button_mask) == 0;
|
||||
}
|
||||
// Button names shown to the player. Bindings are always stored as XInput bits.
|
||||
inline constexpr bool valid_action_binding(std::uint32_t value) noexcept {
|
||||
return valid_controller_combo(value) || keyboard_binding(value);
|
||||
}
|
||||
// Button names shown to the player. Controller chords keep their XInput bits.
|
||||
enum class ControllerStyle : std::uint8_t { xbox, dualshock4, dualsense };
|
||||
struct ControllerInput {
|
||||
bool available{};
|
||||
@@ -19,16 +80,46 @@ struct ControllerInput {
|
||||
// (a pad connecting, disconnecting or switching source) resets latches.
|
||||
unsigned device{};
|
||||
ControllerStyle style{};
|
||||
std::array<std::uint64_t, 4> keys{};
|
||||
};
|
||||
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::string status;
|
||||
};
|
||||
inline std::string controller_combo_label(std::uint32_t value, ControllerStyle style = ControllerStyle::xbox) {
|
||||
if (!value) return "Not bound";
|
||||
if (keyboard_binding(value)) {
|
||||
const auto key_name = [](std::uint32_t key) {
|
||||
if (key == 0x20) return std::string("Space");
|
||||
if (key == 0x11) return std::string("Ctrl");
|
||||
if (key == 0x10) return std::string("Shift");
|
||||
if (key == 0x12) return std::string("Alt");
|
||||
if (key >= 0x70) return "F" + std::to_string(key - 0x6fu);
|
||||
return std::string(1, static_cast<char>(key));
|
||||
};
|
||||
if ((value & 0xc0000000u) == keyboard_binding_tag) return key_name(value & 0xffu);
|
||||
std::string label;
|
||||
const auto append = [&](unsigned wanted) {
|
||||
for (unsigned slot = 0; slot < 5; ++slot) {
|
||||
const auto id = (value >> (slot * 6)) & 0x3fu;
|
||||
if (!id) break;
|
||||
if (id != wanted) continue;
|
||||
if (!label.empty()) label += " + ";
|
||||
label += key_name(keyboard_key_from_id(id));
|
||||
}
|
||||
};
|
||||
for (unsigned id = 50; id <= 52; ++id) append(id);
|
||||
for (unsigned id = 1; id < 50; ++id) append(id);
|
||||
return label;
|
||||
}
|
||||
const bool playstation = style != ControllerStyle::xbox;
|
||||
struct Button { std::uint32_t bit; const char* xbox; const char* playstation; };
|
||||
constexpr Button buttons[]{
|
||||
@@ -46,18 +137,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 +178,30 @@ struct ControllerComboCapture {
|
||||
std::uint32_t chord{};
|
||||
unsigned device{};
|
||||
bool ready{};
|
||||
std::optional<std::uint32_t> update(ControllerInput input) noexcept {
|
||||
if (!input.available || input.device != device) {
|
||||
chord = 0; ready = false; device = input.device;
|
||||
std::array<std::uint64_t, 4> keyboard_chord{};
|
||||
std::optional<std::uint32_t> update(ControllerInput input, bool allow_keyboard = false) noexcept {
|
||||
if (input.device != device || (!input.available && !allow_keyboard)) {
|
||||
chord = 0; ready = false; keyboard_chord = {}; device = input.device;
|
||||
}
|
||||
if (!input.available && !allow_keyboard) return {};
|
||||
const auto buttons = input.buttons & controller_button_mask;
|
||||
const bool keys_held = std::any_of(input.keys.begin(), input.keys.end(), [](auto word) { return word != 0; });
|
||||
if (!ready) { ready = buttons == 0 && (!allow_keyboard || !keys_held); return {}; }
|
||||
if (allow_keyboard && keys_held && !chord) {
|
||||
if (std::popcount(input.keys[0]) + std::popcount(input.keys[1]) +
|
||||
std::popcount(input.keys[2]) + std::popcount(input.keys[3]) >
|
||||
std::popcount(keyboard_chord[0]) + std::popcount(keyboard_chord[1]) +
|
||||
std::popcount(keyboard_chord[2]) + std::popcount(keyboard_chord[3])) keyboard_chord = input.keys;
|
||||
}
|
||||
if (std::any_of(keyboard_chord.begin(), keyboard_chord.end(), [](auto word) { return word != 0; })) {
|
||||
if (!keys_held) {
|
||||
const auto result = keyboard_binding_from_keys(keyboard_chord);
|
||||
keyboard_chord = {}; ready = false;
|
||||
if (result) return result;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (!input.available) return {};
|
||||
const auto buttons = input.buttons & controller_button_mask;
|
||||
if (!ready) { ready = buttons == 0; return {}; }
|
||||
if (std::popcount(buttons) > std::popcount(chord)) chord = buttons;
|
||||
if (!buttons && chord) {
|
||||
const auto result = chord; chord = 0; ready = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
@@ -27,4 +28,21 @@ inline std::optional<ObjectPlacement> parse_object_placement(std::string_view ar
|
||||
: current == ObjectPlacement::host_only ? ObjectPlacement::nobody : ObjectPlacement::everyone;
|
||||
return {};
|
||||
}
|
||||
// How many objects each player may have placed in a session, set by the host or the server:
|
||||
// 0 is no limit beyond the protocol's own (max_object_limit). The host and a server's admins
|
||||
// are not held to it. A player over it keeps what everyone already sees of theirs; only the
|
||||
// objects past the limit are left out.
|
||||
inline constexpr unsigned max_object_limit = 1024;
|
||||
// What a server or a host starts with, until they choose otherwise.
|
||||
inline constexpr unsigned default_object_limit = 100;
|
||||
inline constexpr bool valid_object_limit(std::uint64_t value) noexcept { return value <= max_object_limit; }
|
||||
// Accepts a number from 1 to max_object_limit, and off|none|0 for no limit.
|
||||
inline std::optional<unsigned> parse_object_limit(std::string_view argument) noexcept {
|
||||
if (argument == "off" || argument == "none") return 0U;
|
||||
unsigned value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (argument.empty() || parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !valid_object_limit(value))
|
||||
return {};
|
||||
return value;
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -2,7 +2,7 @@
|
||||
namespace dingosdk {
|
||||
// Everyone a session can hold: the wire protocol's limit, reached by dedicated servers.
|
||||
inline constexpr int multiplayer_player_limit = 250;
|
||||
// Players in a lobby a player hosts (their own game relays everyone, and the lobby is one party).
|
||||
// Players in a lobby a player hosts (their own game relays everyone).
|
||||
inline constexpr int multiplayer_lobby_player_limit = 32;
|
||||
static_assert(multiplayer_lobby_player_limit <= multiplayer_player_limit);
|
||||
}
|
||||
@@ -8,9 +8,25 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
constexpr unsigned max_map_rotation = 1440; // minutes a server's rotation keeps one map, at most
|
||||
// A dedicated server's name: 1 to 64 letters, digits, spaces and - _ / [ ] ( ), with a
|
||||
// letter or digit among them and no space at either end. A server refuses any other
|
||||
// name, and the server browser does not show one.
|
||||
inline constexpr char server_name_rule[] = "1 to 64 letters, numbers, spaces and - _ / [ ] ( )";
|
||||
[[nodiscard]] constexpr bool valid_server_name(std::string_view name) noexcept {
|
||||
if (name.empty() || name.size() > 64 || name.front() == ' ' || name.back() == ' ') return false;
|
||||
bool named{};
|
||||
for (const auto c : name) {
|
||||
const bool word = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
|
||||
if (!word && std::string_view(" -_/[]()").find(c) == std::string_view::npos) return false;
|
||||
named |= word;
|
||||
}
|
||||
return named;
|
||||
}
|
||||
struct MultiplayerLobby {
|
||||
std::uint64_t id{}, owner{};
|
||||
std::string name, map, code;
|
||||
@@ -18,8 +34,23 @@ struct MultiplayerLobby {
|
||||
int players = 1, capacity = multiplayer_player_limit;
|
||||
// A dedicated server rather than a player's lobby: joined by its code.
|
||||
bool dedicated{};
|
||||
// A dedicated server the ReSkate team runs (developer_identity.h): first in the browser.
|
||||
bool official{};
|
||||
int ping = -1; // ms, when the server answered directly
|
||||
// A dedicated server that takes direct connections: its address (host byte order) and UDP
|
||||
// port, 0 for none.
|
||||
std::uint32_t direct_ip{};
|
||||
int direct_port{};
|
||||
// Steam friends skating there now, by name (steam_social.h).
|
||||
std::vector<std::string> friends;
|
||||
};
|
||||
// "Ana, Ben and 2 more": the friends in a server, as a browser row has room for.
|
||||
inline std::string lobby_friends_text(const MultiplayerLobby &lobby, std::size_t named = 2) {
|
||||
std::string text;
|
||||
for (std::size_t i = 0; i < lobby.friends.size() && i < named; ++i) text += (i ? ", " : "") + lobby.friends[i];
|
||||
if (lobby.friends.size() > named) text += " and " + std::to_string(lobby.friends.size() - named) + " more";
|
||||
return text;
|
||||
}
|
||||
struct MultiplayerPlayer {
|
||||
std::uint64_t id{};
|
||||
std::string name;
|
||||
@@ -66,12 +97,20 @@ inline constexpr std::size_t multiplayer_chat_history = 50;
|
||||
struct MultiplayerChatLine {
|
||||
std::uint64_t sequence{}; // local arrival order, increasing within the process
|
||||
std::uint64_t sender{}; // Steam id; 0 for a notice from ReSkate itself
|
||||
std::uint64_t received{}; // local monotonic time (microseconds) the line arrived
|
||||
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", "Admin", "Host", "Friend" or empty).
|
||||
// and a tag shown in a box before the name ("Dev", "Staff", "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 {
|
||||
@@ -94,26 +133,69 @@ struct MultiplayerBan {
|
||||
std::string name; // as they were known when banned; may be empty
|
||||
std::int64_t added{}; // Unix time
|
||||
};
|
||||
inline constexpr float player_distance_least = 50.f, player_distance_unlimited = 1000.f;
|
||||
struct MultiplayerModel {
|
||||
VoiceModel voice;
|
||||
std::vector<MultiplayerBan> bans;
|
||||
unsigned tps = multiplayer_default_tps;
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
// Objects each player may have placed in this session (0: no limit), the limit this
|
||||
// player is held to (0 for the host and a server's admins), and how many they have placed.
|
||||
unsigned object_limit{}, object_limit_own{}, objects_placed{};
|
||||
// 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
|
||||
// game's own). Guest: what that does here, while enforced.
|
||||
bool enforce_tuning{true};
|
||||
std::string tuning_status;
|
||||
// Local display preferences: a lobby shown as the game's own party (Lobby party),
|
||||
// and the floating name label above each peer.
|
||||
bool party_overlay{true}, nametags{true};
|
||||
// Nametag style: ReSkate's own (name, distance, role colours, dots) or the game's.
|
||||
bool custom_nametags{true};
|
||||
// Local display preference: the floating name label above each peer.
|
||||
bool 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.
|
||||
// `chat_bubbles_own` also shows this player's own lines.
|
||||
bool chat_bubbles{true};
|
||||
bool chat_bubbles_own{};
|
||||
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
|
||||
// for most players) and its role colour, and whether they show it, and the animated items
|
||||
// that come with it, to everyone.
|
||||
std::string identity_tag;
|
||||
std::uint32_t identity_tag_colour{};
|
||||
bool identity_tag_shown{true}, identity_items_shown{true};
|
||||
// Local: how each of this player's marked cosmetics is coloured, for the Special page: what
|
||||
// they wear in each slot ("Top", "Shoes", ...), then the parts of their board. mode: 0 what
|
||||
// their list gives (`identity_animation`: "RAINBOW", "GREEN", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient
|
||||
// between the two colours they picked, 3 the first of them alone, 4 the rainbow (offered
|
||||
// when `identity_rainbow`: to the staff). speed: 0 normal, 1 slow, 2 fast.
|
||||
struct IdentityStyle {
|
||||
std::string name;
|
||||
int mode{}, speed{};
|
||||
std::array<float, 3> from{}, to{};
|
||||
};
|
||||
std::vector<IdentityStyle> identity_styles;
|
||||
bool identity_rainbow{};
|
||||
std::string identity_animation;
|
||||
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.
|
||||
@@ -123,6 +205,9 @@ struct MultiplayerModel {
|
||||
unsigned server_ban_total{};
|
||||
// For its admins: the levels (assets) the dedicated server can switch to.
|
||||
std::vector<std::string> server_maps;
|
||||
std::vector<std::string> server_map_pool; // in rotation order; empty: every map
|
||||
unsigned server_map_rotation{}; // minutes per map (0: off)
|
||||
bool server_map_votes{}; // players may vote for a map
|
||||
MultiplayerHostPreferences saved_host;
|
||||
bool force_world_layers{};
|
||||
std::uint64_t host_id{};
|
||||
@@ -134,8 +219,8 @@ struct MultiplayerModel {
|
||||
bool password_required{};
|
||||
int players = 1, capacity = multiplayer_player_limit;
|
||||
std::vector<MultiplayerPlayer> roster;
|
||||
// The local player's party: a lobby is always one party led by the host; on a dedicated
|
||||
// server players form their own (`parties`: invite, leave, kick... are available).
|
||||
// The local player's party. Players form their own, in a lobby as on a dedicated server
|
||||
// (`parties`: invite, leave, kick... are available in any session).
|
||||
std::uint32_t party{};
|
||||
bool party_leader{}, party_open{}, parties{};
|
||||
std::vector<MultiplayerPartyInvite> party_invites; // newest last
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
// What a session does to the local player's physics, between the session (which knows the
|
||||
// host and its rules) and the code that changes physics (the trainer). The tuning asset itself
|
||||
// goes through Extension/Skater/physics_tuning.h; this is the rest.
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
// Physics a player changes outside Gameplay/SkatePhysicsTuning: the trainer's tuning-class
|
||||
// values and trick multipliers (Extension/Trainer/trainer_session.h reads and writes the
|
||||
// bytes; they are opaque here and on the wire). Empty: everything the game's own.
|
||||
inline constexpr std::size_t max_physics_extras = 4096;
|
||||
|
||||
namespace session_physics_detail {
|
||||
inline std::atomic<bool> enforced{};
|
||||
struct Box {
|
||||
std::mutex mutex;
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint64_t revision = 1; // moves whenever `bytes` does; nobody's `seen` starts at it
|
||||
};
|
||||
inline Box &local_box() { static Box value; return value; }
|
||||
inline Box &host_box() { static Box value; return value; }
|
||||
inline void put(Box &box, std::span<const std::uint8_t> bytes) {
|
||||
std::lock_guard lock(box.mutex);
|
||||
if (std::ranges::equal(box.bytes, bytes)) return;
|
||||
box.bytes.assign(bytes.begin(), bytes.end());
|
||||
++box.revision;
|
||||
}
|
||||
inline bool take(Box &box, std::uint64_t &seen, std::vector<std::uint8_t> &bytes) {
|
||||
std::lock_guard lock(box.mutex);
|
||||
if (seen == box.revision) return false;
|
||||
seen = box.revision;
|
||||
bytes = box.bytes;
|
||||
return true;
|
||||
}
|
||||
} // namespace session_physics_detail
|
||||
|
||||
// The local player is a guest of a session whose host (or dedicated server) sets everyone's
|
||||
// physics (the roster's enforce_tuning): their own physics edits stand down, and what the host
|
||||
// shares is theirs. False outside a session and for a host. Set by the session every tick.
|
||||
inline bool session_tuning_enforced() noexcept {
|
||||
return session_physics_detail::enforced.load(std::memory_order_acquire);
|
||||
}
|
||||
inline void set_session_tuning_enforced(bool enforced) noexcept {
|
||||
session_physics_detail::enforced.store(enforced, std::memory_order_release);
|
||||
}
|
||||
|
||||
// The local player's own extras, published by whoever edits them; a host's session sends them
|
||||
// to its guests. The readers copy `bytes` out only when they are not the ones `seen` names
|
||||
// (start `seen` at 0), and say whether they did.
|
||||
inline void set_local_physics_extras(std::span<const std::uint8_t> bytes) {
|
||||
session_physics_detail::put(session_physics_detail::local_box(), bytes);
|
||||
}
|
||||
inline bool local_physics_extras(std::uint64_t &seen, std::vector<std::uint8_t> &bytes) {
|
||||
return session_physics_detail::take(session_physics_detail::local_box(), seen, bytes);
|
||||
}
|
||||
// A guest's host's extras, set by the session: empty until the host sends some, and always for
|
||||
// a dedicated server, whose physics are the game's own. Only meaningful while
|
||||
// session_tuning_enforced().
|
||||
inline void set_host_physics_extras(std::span<const std::uint8_t> bytes) {
|
||||
session_physics_detail::put(session_physics_detail::host_box(), bytes);
|
||||
}
|
||||
inline bool host_physics_extras(std::uint64_t &seen, std::vector<std::uint8_t> &bytes) {
|
||||
return session_physics_detail::take(session_physics_detail::host_box(), seen, bytes);
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -18,4 +18,10 @@ constexpr bool valid_pose_interval(std::uint32_t interval) noexcept {
|
||||
if (interval == multiplayer_pose_interval(rate)) return true;
|
||||
return false;
|
||||
}
|
||||
// The most poses a second one player is sent on a dedicated server, whatever the crowd
|
||||
// around them (Extension/Multiplayer/Session/room.h, crowd_limits); 0: no limit.
|
||||
inline constexpr unsigned crowd_pose_budget = 600, min_crowd_budget = 300, max_crowd_budget = 20000;
|
||||
constexpr bool valid_crowd_budget(unsigned budget) noexcept {
|
||||
return !budget || (budget >= min_crowd_budget && budget <= max_crowd_budget);
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "Engine/Game/Skater/velocity_boost.h"
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
void check(bool condition, const char* message) {
|
||||
if (!condition) { std::cerr << message << '\n'; std::exit(1); }
|
||||
}
|
||||
bool near(float a, float b) { return std::abs(a - b) < .001f; }
|
||||
}
|
||||
|
||||
int main() {
|
||||
using dingosdk::VelocityBoostDirection;
|
||||
using dingosdk::velocity_boost_delta;
|
||||
constexpr auto forward = VelocityBoostDirection::forward;
|
||||
constexpr auto up = VelocityBoostDirection::up;
|
||||
std::array<float, 16> transform{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
||||
// Rotate through world headings; changing preferred stance does not change
|
||||
// this contract: facing-aligned travel boosts forward, fakie travel reverses it.
|
||||
for (int heading = 0; heading < 360; heading += 15) {
|
||||
const float angle = static_cast<float>(heading) * 3.14159265f / 180.f;
|
||||
transform[8] = std::sin(angle);
|
||||
transform[10] = std::cos(angle);
|
||||
for (const float speed : {1.f, 75.f, 300.f}) {
|
||||
for (const float travel : {12.f, -12.f}) {
|
||||
const std::array<float, 3> motion{travel * transform[8], 0, travel * transform[10]};
|
||||
const auto delta = velocity_boost_delta(transform, speed, forward, motion);
|
||||
check(delta.has_value(), "Valid riding boost rejected");
|
||||
const float sign = travel < 0 ? -1.f : 1.f;
|
||||
check(near((*delta)[0], sign * speed * transform[8]) && near((*delta)[2], sign * speed * transform[10]),
|
||||
"Boost opposes riding direction");
|
||||
check(near(std::hypot((*delta)[0], (*delta)[2]), speed), "Configured boost magnitude changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
transform[8] = 0; transform[10] = 1;
|
||||
for (const auto motion : {std::array<float, 3>{0, 0, 0}, {0, 0, -.05f}, {12, 0, 0}, {0, -50, 0}, {0, 50, 0}}) {
|
||||
const auto delta = velocity_boost_delta(transform, 75, forward, motion);
|
||||
check(delta && near((*delta)[2], 75), "Stationary, sideways or vertical motion reversed boost");
|
||||
}
|
||||
// Preserve the original three-dimensional facing axis and magnitude on ramps.
|
||||
transform[9] = .6f; transform[10] = .8f;
|
||||
const auto ramp = velocity_boost_delta(transform, 75, forward, {0, -100, -12});
|
||||
check(ramp && near((*ramp)[1], -45) && near((*ramp)[2], -60), "Fakie ramp direction or magnitude changed");
|
||||
const auto rising = velocity_boost_delta(transform, 75, forward, {0, -100, 12});
|
||||
check(rising && near((*rising)[1], 45) && near((*rising)[2], 60), "Vertical motion overrode forward travel");
|
||||
const auto upward = velocity_boost_delta(transform, 25, up, {0, -100, -12});
|
||||
check(upward && *upward == std::array<float, 3>{0, 25, 0}, "Up Boost changed in fakie");
|
||||
for (const float speed : {0.f, 301.f, std::numeric_limits<float>::quiet_NaN()})
|
||||
check(!velocity_boost_delta(transform, speed, forward), "Invalid forward speed accepted");
|
||||
check(!velocity_boost_delta(transform, 26, up), "Invalid up speed accepted");
|
||||
check(!velocity_boost_delta({}, 75, forward), "Degenerate facing accepted");
|
||||
check(!velocity_boost_delta(transform, 75, forward, {0, 0, std::numeric_limits<float>::infinity()}),
|
||||
"Invalid travel velocity accepted");
|
||||
std::cout << "Velocity boost regressions passed.\n";
|
||||
}
|
||||
@@ -7,7 +7,8 @@ namespace dingosdk {
|
||||
enum class VelocityBoostDirection { forward, up };
|
||||
|
||||
inline std::optional<std::array<float, 3>> velocity_boost_delta(
|
||||
const std::array<float, 16>& transform, float speed, VelocityBoostDirection direction) noexcept {
|
||||
const std::array<float, 16>& transform, float speed, VelocityBoostDirection direction,
|
||||
const std::array<float, 3>& board_velocity = {}) noexcept {
|
||||
const float maximum = direction == VelocityBoostDirection::up ? 25.0f : 300.0f;
|
||||
if (!std::isfinite(speed) || speed < 1.0f || speed > maximum) return {};
|
||||
if (direction == VelocityBoostDirection::up) return std::array<float, 3>{0, speed, 0};
|
||||
@@ -17,6 +18,16 @@ inline std::optional<std::array<float, 3>> velocity_boost_delta(
|
||||
if (!std::isfinite(length) || length <= .01f) return {};
|
||||
const auto scale = speed / std::sqrt(length);
|
||||
for (auto& component : delta) component *= scale;
|
||||
// Fakie travel opposes the skater's facing axis. Use horizontal motion so
|
||||
// falling/rising cannot reverse a boost, and retain facing at rest or sideways.
|
||||
const float horizontal_length = std::hypot(delta[0], delta[2]);
|
||||
if (horizontal_length > .01f) {
|
||||
const float travel = (delta[0] * board_velocity[0] + delta[2] * board_velocity[2]) / horizontal_length;
|
||||
if (!std::isfinite(travel)) return {};
|
||||
// Ignore tiny backwards drift around standstill (0.1 world units/s).
|
||||
if (travel < -.1f)
|
||||
for (auto& component : delta) component = -component;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "cas_codec.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <Windows.h>
|
||||
@@ -68,7 +69,7 @@ HMODULE oodle(const std::filesystem::path& gameRoot) {
|
||||
std::ranges::sort(matches);
|
||||
handle = LoadLibraryExW(matches.back().c_str(), nullptr,
|
||||
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
if (!handle) throw std::runtime_error("Cannot load " + matches.back().string() +
|
||||
if (!handle) throw std::runtime_error("Cannot load " + path_utf8(matches.back()) +
|
||||
" (Windows error " + std::to_string(GetLastError()) + ")");
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -631,4 +631,11 @@ Document read_document(const std::span<const std::byte> bytes) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Guid read_file_guid(const std::span<const std::byte> bytes) {
|
||||
BinaryReader reader(bytes);
|
||||
const auto chunks = read_chunks(reader);
|
||||
reader.seek(unique_chunk(chunks, efix, "EFIX").begin);
|
||||
return reader.guid();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -194,5 +194,8 @@ struct Document {
|
||||
};
|
||||
|
||||
[[nodiscard]] Document read_document(std::span<const std::byte> bytes);
|
||||
// The document's own guid, which is what another document's import names it by,
|
||||
// without reading the rest.
|
||||
[[nodiscard]] Guid read_file_guid(std::span<const std::byte> bytes);
|
||||
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::frostbite::ebx {
|
||||
namespace {
|
||||
@@ -81,6 +85,170 @@ const Value::Array* root_array(const Document& document, std::string_view name)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// A type's name, which is what two documents that number their types
|
||||
// independently have in common. Empty for a descriptor that names none.
|
||||
std::string type_or_none(const Document& document, std::int32_t descriptor) {
|
||||
return descriptor >= 0 && static_cast<std::size_t>(descriptor) < document.types.size()
|
||||
? document.types[static_cast<std::size_t>(descriptor)].name : std::string{};
|
||||
}
|
||||
|
||||
constexpr auto unmatched = static_cast<std::size_t>(-1);
|
||||
|
||||
// Which of an edit's instances each of the base's is: the root is the root, an
|
||||
// exported one is found by its guid, and an internal one, which has nothing
|
||||
// else to go by, by its place among the internal ones. An edit that added or
|
||||
// dropped an internal instance has moved the rest along, so then none of them
|
||||
// is matched. Two that are not of one type are not a match either.
|
||||
std::vector<std::size_t> match_instances(const Document& base, const Document& edit) {
|
||||
std::vector<std::size_t> match(base.instances.size(), unmatched);
|
||||
if (base.instances.empty() || edit.instances.empty()) return match;
|
||||
match[0] = 0;
|
||||
std::map<Guid, std::size_t> exported;
|
||||
std::vector<std::size_t> internal, baseInternal;
|
||||
for (std::size_t index = 1; index < edit.instances.size(); ++index) {
|
||||
if (edit.instances[index].exported) exported.emplace(edit.instances[index].instanceGuid, index);
|
||||
else internal.push_back(index);
|
||||
}
|
||||
for (std::size_t index = 1; index < base.instances.size(); ++index) {
|
||||
if (!base.instances[index].exported) { baseInternal.push_back(index); continue; }
|
||||
if (const auto found = exported.find(base.instances[index].instanceGuid); found != exported.end())
|
||||
match[index] = found->second;
|
||||
}
|
||||
if (internal.size() == baseInternal.size())
|
||||
for (std::size_t at = 0; at < internal.size(); ++at) match[baseInternal[at]] = internal[at];
|
||||
for (std::size_t index = 0; index < match.size(); ++index)
|
||||
if (match[index] != unmatched && type_or_none(base, base.instances[index].descriptor) !=
|
||||
type_or_none(edit, edit.instances[match[index]].descriptor))
|
||||
match[index] = unmatched;
|
||||
return match;
|
||||
}
|
||||
|
||||
bool same(double left, double right) { return std::bit_cast<std::uint64_t>(left) == std::bit_cast<std::uint64_t>(right); }
|
||||
bool same(const ResourceReference& left, const ResourceReference& right) { return left.id == right.id; }
|
||||
template <typename Type> bool same(const Type& left, const Type& right) { return left == right; }
|
||||
|
||||
// Finds what one edit changed in place. The base and the edit are walked side
|
||||
// by side, along with the result: it began as a copy of the base, so it has the
|
||||
// same shape, and may already hold what an earlier edit changed. Nothing is
|
||||
// written until the whole edit has been seen, so an edit either has all its
|
||||
// values taken or none of them.
|
||||
class InPlace final {
|
||||
public:
|
||||
InPlace(const Document& base, const Document& edit, const std::vector<std::size_t>& match)
|
||||
: base_(base), edit_(edit), match_(match) {}
|
||||
|
||||
void fields(const Object& base, const Object& edit, Object& result, bool root) {
|
||||
if (base.fields.size() != edit.fields.size() || base.fields.size() != result.fields.size()) { uncarried = true; return; }
|
||||
for (std::size_t index = 0; index < base.fields.size(); ++index) {
|
||||
if (base.fields[index].name != edit.fields[index].name) { uncarried = true; return; }
|
||||
value(base.fields[index].value, edit.fields[index].value, result.fields[index].value, root);
|
||||
}
|
||||
}
|
||||
|
||||
void apply() {
|
||||
for (const auto& [to, from] : changes_) to->data = from->data;
|
||||
}
|
||||
|
||||
std::size_t values{}; // values the edit changed from the base
|
||||
std::size_t contested{}; // of those, ones the result already had changed to something else
|
||||
bool uncarried{}; // a change was seen that cannot be taken
|
||||
bool unread{}; // somewhere a change could not be looked for
|
||||
|
||||
private:
|
||||
template <typename Type> bool plain(const Value& base, const Value& edit, Value& result) {
|
||||
const auto* before = std::get_if<Type>(&base.data);
|
||||
if (!before) return false;
|
||||
const auto& after = std::get<Type>(edit.data);
|
||||
if (same(*before, after)) return true;
|
||||
++values;
|
||||
const auto& current = std::get<Type>(result.data);
|
||||
if (same(current, after)) return true;
|
||||
if (!same(current, *before)) ++contested;
|
||||
changes_.emplace_back(&result, &edit);
|
||||
return true;
|
||||
}
|
||||
|
||||
void value(const Value& base, const Value& edit, Value& result, bool rootList) {
|
||||
if (base.data.index() != edit.data.index() || base.data.index() != result.data.index()) { uncarried = true; return; }
|
||||
if (std::holds_alternative<std::monostate>(base.data)) return;
|
||||
if (plain<bool>(base, edit, result) || plain<std::int64_t>(base, edit, result) ||
|
||||
plain<std::uint64_t>(base, edit, result) || plain<double>(base, edit, result) ||
|
||||
plain<std::string>(base, edit, result) || plain<Guid>(base, edit, result) ||
|
||||
plain<Sha1>(base, edit, result) || plain<ResourceReference>(base, edit, result)) return;
|
||||
if (const auto* before = std::get_if<PointerReference>(&base.data)) {
|
||||
reference(*before, std::get<PointerReference>(edit.data));
|
||||
return;
|
||||
}
|
||||
if (const auto* before = std::get_if<std::shared_ptr<Object>>(&base.data)) {
|
||||
const auto& after = std::get<std::shared_ptr<Object>>(edit.data);
|
||||
const auto& mine = std::get<std::shared_ptr<Object>>(result.data);
|
||||
if (!*before && !after) return;
|
||||
if (!*before || !after || !mine) { uncarried = true; return; }
|
||||
fields(**before, *after, *mine, false);
|
||||
return;
|
||||
}
|
||||
if (const auto* before = std::get_if<Value::Array>(&base.data)) {
|
||||
const auto& after = std::get<Value::Array>(edit.data);
|
||||
auto& mine = std::get<Value::Array>(result.data);
|
||||
// Only a list on the root grows at its end, and what was appended
|
||||
// comes across on its own. A list that shrank, or one deeper in
|
||||
// that changed length, has entries no longer told apart by place.
|
||||
if (rootList ? after.size() < before->size() : after.size() != before->size()) { uncarried = true; return; }
|
||||
if (mine.size() < before->size()) { uncarried = true; return; }
|
||||
for (std::size_t index = 0; index < before->size(); ++index)
|
||||
value((*before)[index], after[index], mine[index], false);
|
||||
return;
|
||||
}
|
||||
if (const auto* before = std::get_if<TypeReference>(&base.data)) {
|
||||
const auto& after = std::get<TypeReference>(edit.data);
|
||||
if (before->primitive != after.primitive) uncarried = true;
|
||||
else if (before->primitive) uncarried |= before->primitiveType != after.primitiveType;
|
||||
else uncarried |= type_or_none(base_, before->descriptor) != type_or_none(edit_, after.descriptor);
|
||||
return;
|
||||
}
|
||||
if (const auto* before = std::get_if<BoxedReference>(&base.data)) {
|
||||
const auto& after = std::get<BoxedReference>(edit.data);
|
||||
// What a boxed value holds is not read, so a change inside one is not seen.
|
||||
if ((before->encodedType == 0) != (after.encodedType == 0)) uncarried = true;
|
||||
else if (before->encodedType != 0) unread = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A reference still has to lead to the same place; one pointed elsewhere
|
||||
// cannot be taken, because what it points at may not be in the result.
|
||||
void reference(const PointerReference& before, const PointerReference& after) {
|
||||
if (before.kind != after.kind) { uncarried = true; return; }
|
||||
if (before.kind == PointerKind::null) return;
|
||||
if (before.index < 0 || after.index < 0) { uncarried |= before.index != after.index; return; }
|
||||
const auto from = static_cast<std::size_t>(before.index), to = static_cast<std::size_t>(after.index);
|
||||
if (before.kind == PointerKind::external) {
|
||||
uncarried |= from >= base_.imports.size() || to >= edit_.imports.size() ||
|
||||
base_.imports[from].fileGuid != edit_.imports[to].fileGuid ||
|
||||
base_.imports[from].classGuid != edit_.imports[to].classGuid;
|
||||
return;
|
||||
}
|
||||
if (from >= match_.size()) { uncarried = true; return; }
|
||||
// An internal instance that could not be matched is not known to have moved.
|
||||
if (match_[from] == unmatched) (base_.instances[from].exported ? uncarried : unread) = true;
|
||||
else uncarried |= match_[from] != to;
|
||||
}
|
||||
|
||||
const Document& base_;
|
||||
const Document& edit_;
|
||||
const std::vector<std::size_t>& match_;
|
||||
std::vector<std::pair<Value*, const Value*>> changes_;
|
||||
};
|
||||
|
||||
template <typename Visit> void each_pointer(Value& value, const Visit& visit) {
|
||||
if (auto* pointer = std::get_if<PointerReference>(&value.data)) { visit(*pointer); return; }
|
||||
if (auto* nested = std::get_if<std::shared_ptr<Object>>(&value.data)) {
|
||||
if (*nested) for (auto& field : (*nested)->fields) each_pointer(field.value, visit);
|
||||
return;
|
||||
}
|
||||
if (auto* list = std::get_if<Value::Array>(&value.data))
|
||||
for (auto& element : *list) each_pointer(element, visit);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace detail {
|
||||
@@ -289,7 +457,7 @@ void sort_instances(Document& document) {
|
||||
} // namespace detail
|
||||
|
||||
Document merge_documents(const Document& base, const std::span<const Document* const> edits,
|
||||
MergeSummary* summary) {
|
||||
MergeSummary* summary, const CarryImport& carry) {
|
||||
auto result = detail::clone_document(base);
|
||||
const auto* baseRoot = base.root();
|
||||
if (!baseRoot || !baseRoot->object) throw std::runtime_error("EBX base has no root object");
|
||||
@@ -302,11 +470,44 @@ Document merge_documents(const Document& base, const std::span<const Document* c
|
||||
baseLengths.emplace(field.name, array->size());
|
||||
|
||||
MergeSummary totals;
|
||||
for (const auto* edit : edits) {
|
||||
// The references the edits so far appended to each root array.
|
||||
std::map<std::string, std::set<std::pair<PointerKind, std::int32_t>>, std::less<>> appended;
|
||||
for (std::size_t index = 0; index < edits.size(); ++index) {
|
||||
const auto* edit = edits[index];
|
||||
if (!edit) continue;
|
||||
const auto* editRoot = edit->root();
|
||||
if (!editRoot || !editRoot->object) throw std::runtime_error("EBX edit has no root object");
|
||||
|
||||
// What the edit changed in place. The result's first instances are still
|
||||
// the base's, in the base's order: carried ones are added behind them,
|
||||
// and nothing is sorted until every edit is in.
|
||||
const auto match = match_instances(base, *edit);
|
||||
InPlace changes(base, *edit, match);
|
||||
for (std::size_t at = 0; at < base.instances.size(); ++at) {
|
||||
const auto& before = base.instances[at];
|
||||
if (match[at] == unmatched) {
|
||||
// An exported instance is found whenever the edit still has it.
|
||||
(before.exported ? changes.uncarried : changes.unread) = true;
|
||||
continue;
|
||||
}
|
||||
const auto& after = edit->instances[match[at]];
|
||||
if (!before.object || !after.object || !result.instances[at].object) {
|
||||
changes.unread |= before.object || after.object;
|
||||
continue;
|
||||
}
|
||||
changes.fields(*before.object, *after.object, *result.instances[at].object, at == 0);
|
||||
}
|
||||
if (changes.uncarried) {
|
||||
totals.uncarried.push_back(index);
|
||||
} else {
|
||||
changes.apply();
|
||||
totals.values += changes.values;
|
||||
totals.contested += changes.contested;
|
||||
}
|
||||
if (changes.unread) totals.unread.push_back(index);
|
||||
|
||||
detail::Carrier mapper(*edit, result);
|
||||
std::map<std::string, std::set<std::pair<PointerKind, std::int32_t>>, std::less<>> mine;
|
||||
|
||||
// Whatever an edit appended to one of the root's arrays comes across,
|
||||
// pulling in the instances those entries point at.
|
||||
@@ -320,10 +521,34 @@ Document merge_documents(const Document& base, const std::span<const Document* c
|
||||
if (!target)
|
||||
throw std::runtime_error("EBX merge cannot find the base array " + field.name);
|
||||
for (auto at = had; at < editArray->size(); ++at) {
|
||||
target->push_back(mapper.value((*editArray)[at]));
|
||||
const auto& entry = (*editArray)[at];
|
||||
const auto* pointer = carry ? std::get_if<PointerReference>(&entry.data) : nullptr;
|
||||
if (pointer && pointer->kind == PointerKind::external && pointer->index >= 0 &&
|
||||
static_cast<std::size_t>(pointer->index) < edit->imports.size()) {
|
||||
const auto& reference = edit->imports[static_cast<std::size_t>(pointer->index)];
|
||||
if (!carry(reference)) {
|
||||
totals.dropped.push_back({index, reference});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
auto carried = mapper.value(entry);
|
||||
// Two edits that append the same thing mean it once. What one edit
|
||||
// lists twice on its own is left as its author wrote it.
|
||||
if (const auto* reference = std::get_if<PointerReference>(&carried.data);
|
||||
reference && reference->kind != PointerKind::null) {
|
||||
const std::pair key{reference->kind, reference->index};
|
||||
if (const auto earlier = appended.find(field.name);
|
||||
earlier != appended.end() && earlier->second.contains(key)) {
|
||||
++totals.repeated;
|
||||
continue;
|
||||
}
|
||||
mine[field.name].insert(key);
|
||||
}
|
||||
target->push_back(std::move(carried));
|
||||
++totals.arrayEntries;
|
||||
}
|
||||
}
|
||||
for (auto& [name, references] : mine) appended[name].merge(references);
|
||||
|
||||
// An instance an edit added but never linked from the root still has to
|
||||
// exist, or the asset it belongs to goes missing.
|
||||
@@ -346,4 +571,51 @@ Document merge_documents(const Document& base, const std::span<const Document* c
|
||||
return result;
|
||||
}
|
||||
|
||||
std::size_t drop_root_references(Document& document, const CarryImport& keep) {
|
||||
auto* root = const_cast<InstanceRecord*>(document.root());
|
||||
if (!keep || !root || !root->object) return 0;
|
||||
std::size_t dropped{};
|
||||
for (auto& field : root->object->fields) {
|
||||
auto* array = std::get_if<Value::Array>(&field.value.data);
|
||||
if (!array) continue;
|
||||
dropped += std::erase_if(*array, [&](const Value& entry) {
|
||||
const auto* pointer = std::get_if<PointerReference>(&entry.data);
|
||||
return pointer && pointer->kind == PointerKind::external && pointer->index >= 0 &&
|
||||
static_cast<std::size_t>(pointer->index) < document.imports.size() &&
|
||||
!keep(document.imports[static_cast<std::size_t>(pointer->index)]);
|
||||
});
|
||||
}
|
||||
// A boxed value is kept as the bytes it was read as, import numbers and
|
||||
// all, so a document with one keeps its imports where they are.
|
||||
if (!dropped || !document.boxedValues.empty()) return dropped;
|
||||
|
||||
std::vector<bool> used(document.imports.size());
|
||||
for (auto& instance : document.instances)
|
||||
if (instance.object)
|
||||
for (auto& field : instance.object->fields)
|
||||
each_pointer(field.value, [&](const PointerReference& pointer) {
|
||||
if (pointer.kind == PointerKind::external && pointer.index >= 0 &&
|
||||
static_cast<std::size_t>(pointer.index) < used.size())
|
||||
used[static_cast<std::size_t>(pointer.index)] = true;
|
||||
});
|
||||
std::vector<std::int32_t> moveTo(used.size(), -1);
|
||||
std::vector<ImportReference> kept;
|
||||
for (std::size_t index = 0; index < used.size(); ++index) {
|
||||
if (!used[index]) continue;
|
||||
moveTo[index] = static_cast<std::int32_t>(kept.size());
|
||||
kept.push_back(document.imports[index]);
|
||||
}
|
||||
if (kept.size() == document.imports.size()) return dropped;
|
||||
for (auto& instance : document.instances)
|
||||
if (instance.object)
|
||||
for (auto& field : instance.object->fields)
|
||||
each_pointer(field.value, [&](PointerReference& pointer) {
|
||||
if (pointer.kind == PointerKind::external && pointer.index >= 0 &&
|
||||
static_cast<std::size_t>(pointer.index) < moveTo.size())
|
||||
pointer.index = moveTo[static_cast<std::size_t>(pointer.index)];
|
||||
});
|
||||
document.imports = std::move(kept);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
} // namespace dingosdk::frostbite::ebx
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ebx_document.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
@@ -12,15 +13,46 @@ struct MergeSummary {
|
||||
std::size_t instances{}; // instances taken from the edits
|
||||
std::size_t arrayEntries{};// root array entries taken from the edits
|
||||
std::size_t renumbered{}; // carried instances whose object id was re-derived
|
||||
// Root array entries left behind because `carry` turned their target down.
|
||||
struct Dropped {
|
||||
std::size_t edit{}; // index into `edits`
|
||||
ImportReference target;
|
||||
};
|
||||
std::vector<Dropped> dropped;
|
||||
std::size_t repeated{}; // appended references an earlier entry had already added, left out
|
||||
std::size_t values{}; // plain values an edit changed in place, taken from it
|
||||
std::size_t contested{}; // of those, values an earlier edit had changed to something else
|
||||
// Edits (indices into `edits`) with a change that did not come across: a reference
|
||||
// pointed somewhere else, a list resized anywhere but at the root's end, an instance gone.
|
||||
std::vector<std::size_t> uncarried;
|
||||
// Edits whose in-place changes could not all be looked for, because their internal
|
||||
// instances do not line up with the base's or the document holds boxed values.
|
||||
std::vector<std::size_t> unread;
|
||||
|
||||
// Every edit's in-place changes are in the result, as far as anyone can tell.
|
||||
[[nodiscard]] bool exact() const noexcept { return uncarried.empty() && unread.empty(); }
|
||||
};
|
||||
|
||||
// Whether a root array entry an edit appended comes across, asked for an entry
|
||||
// that is a reference into another document. Such a reference only resolves
|
||||
// while the document it names is loaded; one that is not loads as a null object.
|
||||
using CarryImport = std::function<bool(const ImportReference&)>;
|
||||
|
||||
// Combines several independently edited copies of one EBX asset with the base
|
||||
// they were all derived from. Each edit contributes the instances it added and
|
||||
// the entries it appended to the root's arrays; anything an edit merely changed
|
||||
// in place stays as the base had it, because two edits to one value cannot both
|
||||
// be honoured. Edits are applied in the order given, so the last one wins a tie.
|
||||
// they were all derived from. Each edit contributes the instances it added, the
|
||||
// entries it appended to the root's arrays (a reference two edits both append is
|
||||
// listed once), and the plain values it changed in place: a number, a string, a
|
||||
// guid. Two edits to one value cannot both be honoured, so edits are applied in
|
||||
// the order given and the last one wins. A change of any other shape (a
|
||||
// reference pointed elsewhere, a list resized in the middle of the document)
|
||||
// stays as the base had it, and the summary names the edit it was in.
|
||||
[[nodiscard]] Document merge_documents(const Document& base,
|
||||
std::span<const Document* const> edits,
|
||||
MergeSummary* summary = nullptr);
|
||||
MergeSummary* summary = nullptr,
|
||||
const CarryImport& carry = {});
|
||||
|
||||
// Takes out the root array entries that refer to a document `keep` turns down,
|
||||
// and the imports nothing refers to afterwards. Returns how many entries went.
|
||||
std::size_t drop_root_references(Document& document, const CarryImport& keep);
|
||||
|
||||
} // namespace dingosdk::frostbite::ebx
|
||||
@@ -0,0 +1,203 @@
|
||||
// A bundle's chunk metadata has one record for each of its chunks: whose the chunk is (the hash of
|
||||
// the resource's name) and, for a texture, the first mip the bundle holds. In the game's bundles
|
||||
// the records are in the order of the chunks' guids, which is not the order the chunks are listed
|
||||
// in. A mod's tool starts from the game's list and writes a record where each chunk it adds or
|
||||
// replaces is LISTED: right for a chunk it adds at the end, but a replaced chunk's record lands
|
||||
// on another chunk's, which is then gone from the mod's list.
|
||||
//
|
||||
// When two mods each ship a copy of one game bundle with chunks of their own (two costumes built
|
||||
// on the same game costume), the merged bundle needs a list that describes every chunk and is in
|
||||
// the game's form. With the game's list alone, or with records taken from where the mods' lists
|
||||
// have them, the costumes' meshes and textures never streamed in.
|
||||
#include "Engine/Vfs/mod_merge_internal.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
|
||||
namespace db = dingosdk::native_db;
|
||||
namespace fb = dingosdk::frostbite;
|
||||
using namespace dingosdk::mods::detail;
|
||||
|
||||
namespace {
|
||||
using Bytes = std::vector<std::byte>;
|
||||
|
||||
int failures = 0;
|
||||
void check(bool condition, const char* what) {
|
||||
if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; }
|
||||
}
|
||||
|
||||
// A chunk's guid: the first byte decides where it sorts.
|
||||
fb::Guid guid(unsigned char first) {
|
||||
fb::Guid value;
|
||||
value.bytes.fill(std::byte{0x11});
|
||||
value.bytes[0] = static_cast<std::byte>(first);
|
||||
return value;
|
||||
}
|
||||
// One chunk's record: the hash of what it belongs to, and for a texture its first mip.
|
||||
struct Record {
|
||||
std::uint64_t owner{};
|
||||
std::optional<std::uint32_t> firstMip;
|
||||
bool operator==(const Record&) const = default;
|
||||
};
|
||||
db::Node number(unsigned type, const char* name, std::uint64_t value, std::size_t size) {
|
||||
db::Node node;
|
||||
node.type = type;
|
||||
node.named = true;
|
||||
node.name = name;
|
||||
for (std::size_t byte = 0; byte < size; ++byte) node.owned.push_back(static_cast<unsigned char>(value >> (8 * byte)));
|
||||
return node;
|
||||
}
|
||||
Bytes list_of(const std::vector<Record>& records) {
|
||||
db::Node list;
|
||||
list.type = 1;
|
||||
list.named = true;
|
||||
list.name = "chunkMeta";
|
||||
list.terminated = true;
|
||||
for (const auto& record : records) {
|
||||
db::Node meta;
|
||||
meta.type = 2;
|
||||
meta.named = true;
|
||||
meta.name = "meta";
|
||||
meta.terminated = true;
|
||||
if (record.firstMip) meta.children.push_back(number(8, "firstMip", *record.firstMip, 4));
|
||||
db::Node row;
|
||||
row.type = 2;
|
||||
row.terminated = true;
|
||||
row.children.push_back(number(9, "h64", record.owner, 8));
|
||||
row.children.push_back(std::move(meta));
|
||||
list.children.push_back(std::move(row));
|
||||
}
|
||||
const auto written = db::write(list);
|
||||
const auto* bytes = reinterpret_cast<const std::byte*>(written.data());
|
||||
return {bytes, bytes + written.size()};
|
||||
}
|
||||
// What a list says, record by record; a record that says nothing reads as owner 0.
|
||||
std::vector<Record> read_list(const Bytes& list) {
|
||||
const std::span<const unsigned char> bytes(reinterpret_cast<const unsigned char*>(list.data()), list.size());
|
||||
std::size_t used{};
|
||||
const auto root = db::read(bytes, "chunk metadata", &used);
|
||||
check(used == bytes.size() && root.type == 1 && root.name == "chunkMeta", "the merged list is one list, named as the game names it");
|
||||
std::vector<Record> records;
|
||||
for (const auto& row : root.children) {
|
||||
Record record;
|
||||
if (const auto* owner = row.field("h64"); owner && owner->payload().size() == 8)
|
||||
std::memcpy(&record.owner, owner->payload().data(), 8);
|
||||
if (const auto* meta = row.field("meta"))
|
||||
if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) {
|
||||
std::uint32_t value{};
|
||||
std::memcpy(&value, mip->payload().data(), 4);
|
||||
record.firstMip = value;
|
||||
}
|
||||
records.push_back(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
ChunkRecord chunk(unsigned char first, std::size_t copy, std::size_t index, std::size_t shipped = ChunkRecord::none) {
|
||||
return {guid(first), copy, index, shipped};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// The game's bundle lists five chunks: something of its own, a texture, a mesh in two chunks
|
||||
// around another texture. Its list has their records by guid: 10, 30, 50, 60, 70.
|
||||
const Record mesh{0x1111}, textureA{0xAAAA, 2}, textureB{0xBBBB, 3}, other{0x9999};
|
||||
const std::vector<fb::Guid> shipped{guid(0x50), guid(0x30), guid(0x70), guid(0x10), guid(0x60)};
|
||||
const std::vector<Record> game{mesh, textureA, other, textureB, mesh};
|
||||
constexpr std::size_t gameList = 0;
|
||||
|
||||
// One costume adds a texture (guid 20) and a mesh chunk (guid 80), listed after the game's five,
|
||||
// with their records at those two places.
|
||||
const Record firstTexture{0xF001, 0}, firstMesh{0xF002};
|
||||
auto first = game;
|
||||
first.insert(first.end(), {firstTexture, firstMesh});
|
||||
// Another, made with an older tool, replaces the game's second texture (guid 60, listed fifth)
|
||||
// with one whose first mip is 1: its record went over the fifth record, the mesh's, with no hash.
|
||||
// It also adds a texture (guid 40).
|
||||
const Record secondTexture{0x5001, 0};
|
||||
auto second = game;
|
||||
second[4] = Record{0, 1};
|
||||
second.push_back(secondTexture);
|
||||
const std::vector<Bytes> lists{list_of(game), list_of(first), list_of(second)};
|
||||
|
||||
// The merged bundle: the game's five as the higher-priority mod has them, then each mod's own.
|
||||
const std::vector<ChunkRecord> merged{chunk(0x50, 2, 0, 0), chunk(0x30, 2, 1, 1), chunk(0x70, 2, 2, 2), chunk(0x10, 2, 3, 3),
|
||||
chunk(0x60, 2, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6), chunk(0x40, 2, 5)};
|
||||
const auto replaced = Record{textureB.owner, 1};
|
||||
const std::vector<Record> expected{mesh, firstTexture, textureA, secondTexture, other, replaced, mesh, firstMesh};
|
||||
const auto list = read_list(merge_chunk_metadata(lists, merged, gameList, shipped));
|
||||
check(list == expected, "a record for every chunk, in the order of the chunks' guids");
|
||||
check(list.size() == merged.size(), "as many records as chunks");
|
||||
check(std::ranges::count(list, mesh) == 2, "the record the older tool wrote over is back: the mesh has both of its");
|
||||
check(std::ranges::find(list, replaced) != list.end() && std::ranges::find(list, textureB) == list.end(),
|
||||
"the replaced texture keeps its own name and takes the first mip the mod gave it");
|
||||
check(std::ranges::none_of(list, [](const Record& record) { return record.owner == 0; }), "no record is left without a hash");
|
||||
// The fault this is for: the game's own list is three records short, and the mods' lists have
|
||||
// their records where the chunks are listed, which is not where the game looks.
|
||||
check(read_list(lists[gameList]).size() + 3 == merged.size(), "the game's own list is three records short of the merged bundle");
|
||||
|
||||
// No mod's chunk in it: the game's list is not written again.
|
||||
const std::vector<ChunkRecord> untouched{chunk(0x50, 0, 0, 0), chunk(0x30, 0, 1, 1), chunk(0x70, 0, 2, 2), chunk(0x10, 0, 3, 3), chunk(0x60, 0, 4, 4)};
|
||||
check(merge_chunk_metadata(lists, untouched, gameList, shipped) == lists[gameList], "a bundle that is all the game's keeps the game's list as it is");
|
||||
// One mod's copy alone is still written in the game's form: its additions go where their guids put them.
|
||||
const std::vector<ChunkRecord> oneMod{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3),
|
||||
chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)};
|
||||
check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped)) ==
|
||||
std::vector<Record>{mesh, firstTexture, textureA, other, textureB, mesh, firstMesh},
|
||||
"one mod's additions are put in guid order with the game's");
|
||||
// A newer tool writes the replaced texture's own hash with its first mip; the first mip is what counts.
|
||||
auto newer = game;
|
||||
newer[4] = Record{textureB.owner, 0};
|
||||
const std::vector<Bytes> withNewer{list_of(game), list_of(newer)};
|
||||
const std::vector<ChunkRecord> keepsNewer{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), chunk(0x60, 1, 4, 4)};
|
||||
check(read_list(merge_chunk_metadata(withNewer, keepsNewer, gameList, shipped)) ==
|
||||
std::vector<Record>{mesh, textureA, other, Record{textureB.owner, 0}, mesh},
|
||||
"a replaced texture's new first mip goes into its own record, and the record written over is back");
|
||||
// A mod that only carries the game's version of a chunk another mod replaced changes nothing of it.
|
||||
const std::vector<ChunkRecord> carried{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3),
|
||||
chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)};
|
||||
check(read_list(merge_chunk_metadata(lists, carried, gameList, shipped))[4] == textureB, "a chunk a mod only carries keeps the game's record");
|
||||
|
||||
// A chunk whose copy has no list, or a shorter one, still takes up a place.
|
||||
const std::vector<ChunkRecord> unknown{chunk(0x50, 0, 0, 0), chunk(0x30, 0, 1, 1), chunk(0x70, 0, 2, 2), chunk(0x10, 0, 3, 3),
|
||||
chunk(0x60, 0, 4, 4), chunk(0x20, ChunkRecord::none, 5), chunk(0x80, 1, 9)};
|
||||
check(read_list(merge_chunk_metadata(lists, unknown, gameList, shipped)) ==
|
||||
std::vector<Record>{mesh, Record{}, textureA, other, textureB, mesh, Record{}},
|
||||
"a chunk no list describes gets a record that says nothing, and the others stay in step");
|
||||
|
||||
// A bundle the game has no list for (it has no chunks there, or does not ship the bundle): every
|
||||
// chunk is a mod's, with the record of the copy it came in. Maps each add their loading screen
|
||||
// to one such bundle.
|
||||
const std::vector<Bytes> modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})};
|
||||
const std::vector<ChunkRecord> theirs{chunk(0x90, 1, 0), chunk(0x20, 1, 1), chunk(0x40, 1, 2)};
|
||||
check(merge_chunk_metadata(modsOnly, theirs) == modsOnly[1], "a bundle that is one mod's copy alone keeps that copy's list");
|
||||
const std::vector<ChunkRecord> both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)};
|
||||
check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector<Record>{firstMesh, secondTexture, firstTexture},
|
||||
"and otherwise each chunk's record from the copy it came in, by guid like any other");
|
||||
// A game list that is not one record a chunk is of some other kind: nothing is put in order by it.
|
||||
const std::vector<Bytes> shortGame{list_of({mesh, textureA}), list_of(first)};
|
||||
check(read_list(merge_chunk_metadata(shortGame, oneMod, gameList, shipped)) == first,
|
||||
"a game list that is not a record a chunk leaves the records where the copies have them");
|
||||
|
||||
// No chunks, no list to write: what the first copy has stays.
|
||||
check(merge_chunk_metadata({}, {}).empty(), "no list from no lists");
|
||||
check(merge_chunk_metadata(lists, {}, gameList, shipped) == lists[gameList], "a bundle without chunks keeps the first copy's list");
|
||||
const std::vector<Bytes> none{Bytes{}, Bytes{}};
|
||||
const std::vector<ChunkRecord> bare{chunk(0x10, ChunkRecord::none, 0), chunk(0x20, ChunkRecord::none, 1)};
|
||||
check(merge_chunk_metadata(none, bare).empty(), "nor from copies that have none");
|
||||
|
||||
// A list that is needed and cannot be read is the caller's to deal with.
|
||||
const std::vector<Bytes> broken{lists[0], Bytes(7, std::byte{0x41})};
|
||||
bool threw{};
|
||||
try { (void)merge_chunk_metadata(broken, oneMod, gameList, shipped); }
|
||||
catch (const std::exception&) { threw = true; }
|
||||
check(threw, "an unreadable list throws");
|
||||
|
||||
if (failures) {
|
||||
std::cerr << "chunk metadata merge: " << failures << " check(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "chunk metadata merge: ok\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -62,6 +62,20 @@ int main(int argc, char** argv) {
|
||||
check(stadium != travel.access_points.end() && stadium->second == std::vector<std::string>{"location_stadium"},
|
||||
"stadium entrance offers only the first stadium");
|
||||
check(travel.access_points.size() == 5, "five access points");
|
||||
check(catalogs.music_playlists.size() == 54, "54 music playlists");
|
||||
if (const auto station = catalogs.music_playlists.find("0_Licensed_All"); station != catalogs.music_playlists.end())
|
||||
check(station->second.name == "San Van Soundtrack" && station->second.tracks.size() == 107 &&
|
||||
station->second.tracks.front() == "2hollis - flash" &&
|
||||
station->second.artwork == "cdn:/0e63e2111f9088448ae261ab0f930a4a", "San Van Soundtrack station");
|
||||
else check(false, "San Van Soundtrack station");
|
||||
if (const auto ballroom = catalogs.music_playlists.find("1920sBallroom"); ballroom != catalogs.music_playlists.end())
|
||||
check(ballroom->second.name == "Ballroom 237" && ballroom->second.tracks.size() == 3, "Ballroom station");
|
||||
else check(false, "Ballroom station");
|
||||
if (const auto art = catalogs.music_song_artwork.find("Freddie Gibbs, The Alchemist, Anderson .Paak - Ensalada");
|
||||
art != catalogs.music_song_artwork.end())
|
||||
check(art->second == "cdn:/ad89945108fd5d7d6fc94ccb33c0f500", "a song's cover art");
|
||||
else check(false, "a song's cover art");
|
||||
check(catalogs.music_song_artwork.size() >= 100, "the licensed songs have cover art");
|
||||
if (argc > 2) {
|
||||
Json dump{{"items", catalogs.items}, {"challenges", catalogs.challenges}, {"entitlements", Json::array()}};
|
||||
for (const auto& id : catalogs.entitlements) dump["entitlements"].push_back(id);
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// The document merge, on documents made in memory, so it needs no game files.
|
||||
//
|
||||
// Several mods each ship their own copy of one of the game's assets. merge_documents puts the
|
||||
// copies together over the game's: what each added, and what each changed in place. The cases:
|
||||
// * values two edits changed in place both arrive, and one both changed is the later edit's;
|
||||
// * an edit that also changed something that cannot be taken (a reference pointed elsewhere, a
|
||||
// list resized) has none of its values taken, and the summary names it;
|
||||
// * an exported instance is found by its guid wherever the edit keeps it, and internal
|
||||
// instances that do not line up are reported as not looked at;
|
||||
// * a reference two edits both append is listed once, and one edit's own repeat is left alone;
|
||||
// * drop_root_references takes entries out of the root's lists with the imports they used.
|
||||
#include "Engine/Resource/ebx_carry.h"
|
||||
#include "Engine/Resource/ebx_merge.h"
|
||||
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ebx = dingosdk::frostbite::ebx;
|
||||
using dingosdk::frostbite::Guid;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
void expect(bool ok, const std::string& what) {
|
||||
if (ok) return;
|
||||
std::cerr << "FAIL: " << what << '\n';
|
||||
++failures;
|
||||
}
|
||||
|
||||
Guid guid(std::uint8_t seed) {
|
||||
Guid value;
|
||||
value.bytes.fill(static_cast<std::byte>(seed));
|
||||
return value;
|
||||
}
|
||||
|
||||
template <typename Type> ebx::Value value_of(Type data) {
|
||||
ebx::Value value;
|
||||
value.data = std::move(data);
|
||||
return value;
|
||||
}
|
||||
ebx::Value number(std::int64_t value) { return value_of(value); }
|
||||
ebx::Value real(double value) { return value_of(value); }
|
||||
ebx::Value text(std::string value) { return value_of(std::move(value)); }
|
||||
// A reference to another document, by the place of its import, and one to an instance of this one.
|
||||
ebx::Value outside(std::int32_t import) { return value_of(ebx::PointerReference{ebx::PointerKind::external, import}); }
|
||||
ebx::Value inside(std::int32_t instance) { return value_of(ebx::PointerReference{ebx::PointerKind::internal, instance}); }
|
||||
ebx::Value list(ebx::Value::Array entries) { return value_of(std::move(entries)); }
|
||||
|
||||
std::shared_ptr<ebx::Object> object(std::int32_t type, std::vector<std::pair<std::string, ebx::Value>> fields) {
|
||||
auto result = std::make_shared<ebx::Object>();
|
||||
result->descriptor = type;
|
||||
for (auto& [name, value] : fields) result->fields.push_back({result->fields.size(), std::move(name), std::move(value)});
|
||||
return result;
|
||||
}
|
||||
ebx::InstanceRecord instance(std::int32_t type, std::shared_ptr<ebx::Object> fields, const Guid* exported = nullptr) {
|
||||
ebx::InstanceRecord record;
|
||||
record.descriptor = type;
|
||||
record.exported = exported != nullptr;
|
||||
if (exported) record.instanceGuid = *exported;
|
||||
record.object = std::move(fields);
|
||||
return record;
|
||||
}
|
||||
|
||||
constexpr std::int32_t list_type = 0, entry_type = 1, detail_type = 2;
|
||||
const Guid entry_guid = guid(0xA1);
|
||||
|
||||
// The game's asset: a root with plain values, a list of two other documents and a nested
|
||||
// structure; an exported instance the root points at; and an internal one that one points at.
|
||||
ebx::Document game() {
|
||||
ebx::Document document;
|
||||
document.fileGuid = guid(1);
|
||||
document.rootType = "ListAsset";
|
||||
document.types.resize(3);
|
||||
document.types[list_type].name = "ListAsset";
|
||||
document.types[entry_type].name = "Entry";
|
||||
document.types[detail_type].name = "Detail";
|
||||
document.imports = {{guid(0x10), guid(0xC0)}, {guid(0x11), guid(0xC0)}};
|
||||
document.instances.push_back(instance(list_type, object(list_type, {
|
||||
{"Volume", number(5)}, {"Gain", real(1.0)}, {"Title", text("game")},
|
||||
{"Items", list({outside(0), outside(1)})}, {"First", inside(1)},
|
||||
{"Tuning", value_of(object(detail_type, {{"Weights", list({number(1), number(2)})}, {"Scale", real(2.0)}}))}})));
|
||||
document.instances.push_back(instance(entry_type, object(entry_type, {{"Cost", number(10)}, {"Detail", inside(2)}}), &entry_guid));
|
||||
document.instances.push_back(instance(detail_type, object(detail_type, {{"Depth", number(3)}})));
|
||||
return document;
|
||||
}
|
||||
ebx::Document copy(const ebx::Document& document) { return ebx::detail::clone_document(document); }
|
||||
|
||||
ebx::Value& field(ebx::Object& from, std::string_view name) {
|
||||
for (auto& entry : from.fields)
|
||||
if (entry.name == name) return entry.value;
|
||||
throw std::runtime_error("no field " + std::string(name));
|
||||
}
|
||||
ebx::Value& field(ebx::Document& document, std::size_t at, std::string_view name) { return field(*document.instances.at(at).object, name); }
|
||||
template <typename Type> const Type& read(const ebx::Document& document, std::size_t at, std::string_view name) {
|
||||
return std::get<Type>(field(*document.instances.at(at).object, name).data);
|
||||
}
|
||||
ebx::Object& tuning(ebx::Document& document) { return *std::get<std::shared_ptr<ebx::Object>>(field(document, 0, "Tuning").data); }
|
||||
ebx::Value::Array& items(ebx::Document& document) { return std::get<ebx::Value::Array>(field(document, 0, "Items").data); }
|
||||
// The documents a list names, in order.
|
||||
std::vector<Guid> named(const ebx::Document& document) {
|
||||
std::vector<Guid> result;
|
||||
for (const auto& entry : std::get<ebx::Value::Array>(field(*document.instances.front().object, "Items").data))
|
||||
result.push_back(document.imports.at(static_cast<std::size_t>(std::get<ebx::PointerReference>(entry.data).index)).fileGuid);
|
||||
return result;
|
||||
}
|
||||
void append(ebx::Document& document, const Guid& target) {
|
||||
document.imports.push_back({target, guid(0xC0)});
|
||||
items(document).push_back(outside(static_cast<std::int32_t>(document.imports.size() - 1)));
|
||||
}
|
||||
// Where the exported instance with this guid is.
|
||||
std::size_t find(const ebx::Document& document, const Guid& wanted) {
|
||||
for (std::size_t index = 0; index < document.instances.size(); ++index)
|
||||
if (document.instances[index].exported && document.instances[index].instanceGuid == wanted) return index;
|
||||
throw std::runtime_error("no such exported instance");
|
||||
}
|
||||
|
||||
ebx::Document merge(const ebx::Document& base, std::initializer_list<const ebx::Document*> edits, ebx::MergeSummary& summary) {
|
||||
const std::vector<const ebx::Document*> documents(edits);
|
||||
return ebx::merge_documents(base, documents, &summary);
|
||||
}
|
||||
|
||||
void values_from_every_edit() {
|
||||
const auto base = game();
|
||||
auto first = copy(base), second = copy(base);
|
||||
field(first, 0, "Volume") = number(9);
|
||||
field(first, 0, "Title") = text("first");
|
||||
field(second, 0, "Gain") = real(0.5);
|
||||
field(tuning(second), "Scale") = real(4.0);
|
||||
std::get<ebx::Value::Array>(field(tuning(second), "Weights").data)[1] = number(7);
|
||||
field(second, 1, "Cost") = number(20);
|
||||
field(second, 2, "Depth") = number(8);
|
||||
ebx::MergeSummary summary;
|
||||
auto merged = merge(base, {&first, &second}, summary);
|
||||
expect(read<std::int64_t>(merged, 0, "Volume") == 9 && read<std::string>(merged, 0, "Title") == "first",
|
||||
"values: the first edit's values are in the result");
|
||||
expect(read<double>(merged, 0, "Gain") == 0.5 && std::get<double>(field(tuning(merged), "Scale").data) == 4.0 &&
|
||||
std::get<std::int64_t>(std::get<ebx::Value::Array>(field(tuning(merged), "Weights").data)[1].data) == 7,
|
||||
"values: the second edit's values are in the result, the nested ones too");
|
||||
expect(read<std::int64_t>(merged, 1, "Cost") == 20 && read<std::int64_t>(merged, 2, "Depth") == 8,
|
||||
"values: values in an exported and in an internal instance are in the result");
|
||||
expect(summary.values == 7 && summary.contested == 0 && summary.exact() && !summary.instances && !summary.arrayEntries,
|
||||
"values: seven values taken, none disagreed, nothing added (" + std::to_string(summary.values) + " taken)");
|
||||
expect(named(merged) == named(base) && merged.instances.size() == base.instances.size(), "values: nothing else moved");
|
||||
}
|
||||
|
||||
void one_value_from_two_edits(bool same) {
|
||||
const std::string label = same ? "same value twice" : "contested value";
|
||||
const auto base = game();
|
||||
auto lower = copy(base), higher = copy(base);
|
||||
field(lower, 0, "Volume") = number(9);
|
||||
field(higher, 0, "Volume") = number(same ? 9 : 11);
|
||||
ebx::MergeSummary summary;
|
||||
const auto merged = merge(base, {&lower, &higher}, summary);
|
||||
expect(read<std::int64_t>(merged, 0, "Volume") == (same ? 9 : 11), label + ": the later edit's value is the one kept");
|
||||
expect(summary.values == 2 && summary.contested == (same ? 0u : 1u) && summary.exact(),
|
||||
label + ": " + (same ? "no" : "one") + " disagreement is counted");
|
||||
}
|
||||
|
||||
// What cannot be taken: the edit that made such a change keeps all its values out, so the result
|
||||
// never has half of what a mod did to the game's own content.
|
||||
void a_change_that_cannot_be_taken(int kind) {
|
||||
static constexpr std::array labels{"retargeted reference", "shorter root list", "resized nested list", "instance gone"};
|
||||
const std::string label = labels[static_cast<std::size_t>(kind)];
|
||||
const auto base = game();
|
||||
auto reshaped = copy(base), plain = copy(base);
|
||||
field(reshaped, 0, "Volume") = number(9);
|
||||
if (kind == 0) items(reshaped)[0] = outside(1);
|
||||
else if (kind == 1) items(reshaped).pop_back();
|
||||
else if (kind == 2) std::get<ebx::Value::Array>(field(tuning(reshaped), "Weights").data).push_back(number(3));
|
||||
else {
|
||||
// The exported instance is not in the edit: the root points at the internal one instead.
|
||||
reshaped.instances.erase(reshaped.instances.begin() + 1);
|
||||
field(reshaped, 0, "First") = inside(1);
|
||||
}
|
||||
field(plain, 0, "Title") = text("plain");
|
||||
ebx::MergeSummary summary;
|
||||
const auto merged = merge(base, {&reshaped, &plain}, summary);
|
||||
expect(read<std::int64_t>(merged, 0, "Volume") == 5, label + ": none of that edit's values are taken");
|
||||
expect(read<std::string>(merged, 0, "Title") == "plain", label + ": the other edit's value is");
|
||||
expect(summary.uncarried == std::vector<std::size_t>{0} && !summary.exact() && summary.values == 1,
|
||||
label + ": the summary names the edit");
|
||||
expect(named(merged) == named(base), label + ": the game's list is as it was");
|
||||
}
|
||||
|
||||
// An edit that added an exported instance has moved the game's along; they are still found.
|
||||
void instances_found_by_guid() {
|
||||
const auto base = game();
|
||||
auto edit = copy(base);
|
||||
const auto added = guid(0x05); // sorts ahead of the game's
|
||||
edit.instances.insert(edit.instances.begin() + 1,
|
||||
instance(entry_type, object(entry_type, {{"Cost", number(1)}, {"Detail", value_of(ebx::PointerReference{})}}), &added));
|
||||
field(edit, 0, "First") = inside(2);
|
||||
field(edit, 2, "Detail") = inside(3);
|
||||
field(edit, 2, "Cost") = number(30);
|
||||
ebx::MergeSummary summary;
|
||||
const auto merged = merge(base, {&edit}, summary);
|
||||
expect(summary.uncarried.empty() && summary.unread.empty() && summary.values == 1 && summary.instances == 1,
|
||||
"by guid: one value taken and one instance added, nothing left behind");
|
||||
expect(read<std::int64_t>(merged, find(merged, entry_guid), "Cost") == 30, "by guid: the game's instance has the edit's value");
|
||||
expect(read<std::int64_t>(merged, find(merged, added), "Cost") == 1, "by guid: the added instance came across");
|
||||
expect(find(merged, added) < find(merged, entry_guid), "by guid: exported instances are in guid order");
|
||||
expect(static_cast<std::size_t>(read<ebx::PointerReference>(merged, 0, "First").index) == find(merged, entry_guid),
|
||||
"by guid: the root still points at the game's instance");
|
||||
}
|
||||
|
||||
// An internal instance has no guid. With one more of them in the edit the places say nothing,
|
||||
// so they are not compared, and the summary says the edit was not looked at in full.
|
||||
void internal_instances_that_do_not_line_up() {
|
||||
const auto base = game();
|
||||
auto edit = copy(base);
|
||||
edit.instances.push_back(instance(detail_type, object(detail_type, {{"Depth", number(4)}})));
|
||||
field(edit, 0, "Volume") = number(9);
|
||||
ebx::MergeSummary summary;
|
||||
const auto merged = merge(base, {&edit}, summary);
|
||||
expect(read<std::int64_t>(merged, 0, "Volume") == 9, "internal: the value on the root is taken");
|
||||
expect(summary.uncarried.empty() && summary.unread == std::vector<std::size_t>{0} && !summary.exact(),
|
||||
"internal: the edit is reported as not looked at in full");
|
||||
}
|
||||
|
||||
void appended_references() {
|
||||
const auto base = game();
|
||||
auto first = copy(base), second = copy(base), third = copy(base);
|
||||
append(first, guid(0x20));
|
||||
append(first, guid(0x21));
|
||||
append(second, guid(0x21)); // the first edit's as well
|
||||
append(second, guid(0x22));
|
||||
append(third, guid(0x23)); // twice in one edit: its author's doing
|
||||
append(third, guid(0x23));
|
||||
ebx::MergeSummary summary;
|
||||
const auto merged = merge(base, {&first, &second, &third}, summary);
|
||||
const std::vector<Guid> expected{guid(0x10), guid(0x11), guid(0x20), guid(0x21), guid(0x22), guid(0x23), guid(0x23)};
|
||||
expect(named(merged) == expected, "appended: each reference once, in the order of the edits, one edit's own repeat kept (" +
|
||||
std::to_string(named(merged).size()) + " entries)");
|
||||
expect(summary.arrayEntries == 5 && summary.repeated == 1 && summary.exact(), "appended: five entries taken, one repeat left out");
|
||||
}
|
||||
|
||||
void dropped_references() {
|
||||
auto document = game();
|
||||
append(document, guid(0x20));
|
||||
append(document, guid(0x21));
|
||||
const auto gone = ebx::drop_root_references(document, [](const ebx::ImportReference& reference) {
|
||||
return reference.fileGuid != guid(0x11) && reference.fileGuid != guid(0x20); });
|
||||
expect(gone == 2, "dropped: two entries went (" + std::to_string(gone) + ")");
|
||||
expect((named(document) == std::vector<Guid>{guid(0x10), guid(0x21)}), "dropped: the others are left, in order");
|
||||
expect(document.imports.size() == 2, "dropped: the imports nothing uses went with them (" +
|
||||
std::to_string(document.imports.size()) + " left)");
|
||||
|
||||
// A boxed value is kept as bytes with its import numbers in them, so the imports stay put.
|
||||
auto boxed = game();
|
||||
boxed.boxedValues.emplace_back();
|
||||
expect(ebx::drop_root_references(boxed, [](const ebx::ImportReference& reference) { return reference.fileGuid != guid(0x10); }) == 1 &&
|
||||
boxed.imports.size() == 2 && named(boxed) == std::vector<Guid>{guid(0x11)},
|
||||
"dropped: a document with a boxed value keeps its imports where they are");
|
||||
auto untouched = game();
|
||||
expect(ebx::drop_root_references(untouched, [](const ebx::ImportReference&) { return true; }) == 0 &&
|
||||
untouched.imports.size() == 2, "dropped: nothing goes when every entry is kept");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() try {
|
||||
values_from_every_edit();
|
||||
one_value_from_two_edits(false);
|
||||
one_value_from_two_edits(true);
|
||||
for (int kind = 0; kind < 4; ++kind) a_change_that_cannot_be_taken(kind);
|
||||
instances_found_by_guid();
|
||||
internal_instances_that_do_not_line_up();
|
||||
appended_references();
|
||||
dropped_references();
|
||||
if (failures) {
|
||||
std::cerr << failures << " failure(s)\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "ebx merge: ok\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
// Cosmetic mods whose item lists name an item the merged bundle does not hold.
|
||||
//
|
||||
// Every cosmetic mod ships its own copy of the game's shared customization bundle: the game's
|
||||
// assets, one of the game's item lists with the mod's item appended, and the item. The merge
|
||||
// combines the lists, and a bundle keeps one asset per name. The game reads each entry of an
|
||||
// item list at startup without checking it, so an entry whose item is not in the bundle is a
|
||||
// crash on every launch (Skate.exe+0x891e07, 2026-10-04). Two ways to get one:
|
||||
// * two mods each add a different item under the same asset name, and only one can stay;
|
||||
// * a mod's list names an item no installed mod ships (it was built on top of another mod).
|
||||
// Either way the entry has to go, and everything else of every mod has to stay. (A bundle only
|
||||
// one mod ships is not merged at all, it passes through as that mod built it, so every case
|
||||
// here has two mods shipping the shared bundle.)
|
||||
//
|
||||
// The same mods also show what else the merge does with two copies of one of the game's assets:
|
||||
// * an item two mods both list is in the merged list once;
|
||||
// * values two mods changed in place in one asset are both in the merged asset, and where that
|
||||
// cannot be done the merge keeps one mod's copy whole and says whose changes are missing;
|
||||
// * the list other mods' copies of the bundle are given leaves out an item that is not in them.
|
||||
//
|
||||
// The mods are made here from the installed game's own list and one of its items.
|
||||
// Arguments: <Skate folder> [<folder>]; skipped when the game is not there. With a second
|
||||
// argument the two mods of the first case are also left in that folder, stamped for this game,
|
||||
// to try a build against in the game itself.
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
#include "Engine/Resource/binary_bundle.h"
|
||||
#include "Engine/Resource/cas_codec.h"
|
||||
#include "Engine/Resource/ebx_carry.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
#include "Engine/Resource/ebx_writer.h"
|
||||
#include "Engine/Resource/toc.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Engine/Vfs/mod_merge_internal.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
namespace fb = dingosdk::frostbite;
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::mods::detail;
|
||||
|
||||
namespace {
|
||||
using Bytes = std::vector<std::byte>;
|
||||
constexpr char shared_bundle[] = "win32/characters/customization/configs/cas_main_sharedbundle";
|
||||
constexpr char list_type[] = "delmaritemcollectionasset";
|
||||
|
||||
int failures = 0;
|
||||
void expect(bool ok, const std::string& what) {
|
||||
if (ok) return;
|
||||
std::cerr << "FAIL: " << what << '\n';
|
||||
++failures;
|
||||
}
|
||||
|
||||
fb::Guid guid(std::uint8_t seed) {
|
||||
fb::Guid value;
|
||||
for (std::size_t index = 0; index < value.bytes.size(); ++index)
|
||||
value.bytes[index] = static_cast<std::byte>(static_cast<std::uint8_t>(seed + index));
|
||||
return value;
|
||||
}
|
||||
|
||||
// The shared bundle as one data layer has it: the game's, a mod's or the merged patch's.
|
||||
struct Shared {
|
||||
fb::TocBundle bundle;
|
||||
Listing listing;
|
||||
};
|
||||
Shared shared_in(const CasStore& store, const fs::path& root, const fs::path& baseRoot, const fs::path& gameRoot) {
|
||||
for (const auto& bundle : fb::read_toc(read_file(root / L"Win32" / L"items.toc")).bundles) {
|
||||
if (lower(bundle.name) != shared_bundle) continue;
|
||||
if (auto listing = list_bundle(store, root, baseRoot, bundle, gameRoot)) return {bundle, std::move(*listing)};
|
||||
break;
|
||||
}
|
||||
throw std::runtime_error(std::string("no readable ") + shared_bundle + " in " + root.string());
|
||||
}
|
||||
|
||||
bool list_name(std::string_view name) {
|
||||
const auto text = lower(name);
|
||||
return text.starts_with("items/") && text.find("collection") != std::string::npos;
|
||||
}
|
||||
|
||||
// The items a list names, by the guid of each one's own document.
|
||||
std::vector<fb::Guid> listed(const fb::ebx::Document& list) {
|
||||
const auto* root = list.root();
|
||||
const auto* items = root && root->object ? root->object->find("Items") : nullptr;
|
||||
const auto* entries = items ? std::get_if<fb::ebx::Value::Array>(&items->value.data) : nullptr;
|
||||
if (!entries) throw std::runtime_error("an item list has no Items");
|
||||
std::vector<fb::Guid> result;
|
||||
for (const auto& entry : *entries) {
|
||||
const auto* pointer = std::get_if<fb::ebx::PointerReference>(&entry.data);
|
||||
if (!pointer || pointer->kind != fb::ebx::PointerKind::external)
|
||||
throw std::runtime_error("an item list entry is not a reference to another document");
|
||||
result.push_back(list.imports.at(static_cast<std::size_t>(pointer->index)).fileGuid);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The game's list with more items appended, the way a cosmetic mod ships it.
|
||||
Bytes list_with(const fb::ebx::Document& game, std::initializer_list<fb::ebx::ImportReference> items) {
|
||||
auto list = fb::ebx::detail::clone_document(game);
|
||||
for (auto& field : list.instances.front().object->fields) {
|
||||
if (field.name != "Items") continue;
|
||||
auto& entries = std::get<fb::ebx::Value::Array>(field.value.data);
|
||||
for (const auto& item : items) {
|
||||
fb::ebx::Value entry;
|
||||
entry.data = fb::ebx::PointerReference{fb::ebx::PointerKind::external,
|
||||
static_cast<std::int32_t>(list.imports.size())};
|
||||
list.imports.push_back(item);
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
return fb::ebx::write_document(list);
|
||||
}
|
||||
|
||||
// One of the game's items under a name and identity of its own: what a cosmetic mod adds.
|
||||
struct Item {
|
||||
std::string name;
|
||||
fb::ebx::ImportReference identity;
|
||||
Bytes payload;
|
||||
};
|
||||
Item item_from(const fb::ebx::Document& donor, const std::string& name, std::uint8_t seed) {
|
||||
auto item = fb::ebx::detail::clone_document(donor);
|
||||
auto& root = item.instances.front();
|
||||
item.fileGuid = guid(seed);
|
||||
root.instanceGuid = guid(static_cast<std::uint8_t>(seed + 0x40));
|
||||
const auto key = name.substr(name.rfind('/') + 1);
|
||||
std::uint32_t hash = 5381; // the game's own hash of an item's key
|
||||
for (const unsigned char character : key) hash = hash * 33U ^ character;
|
||||
for (auto& field : root.object->fields) {
|
||||
if (field.name == "Name") field.value.data = name;
|
||||
else if (field.name == "Key") field.value.data = key;
|
||||
else if (field.name == "HashedAssetKey") {
|
||||
if (std::holds_alternative<std::int64_t>(field.value.data)) field.value.data = static_cast<std::int64_t>(hash);
|
||||
else field.value.data = static_cast<std::uint64_t>(hash);
|
||||
}
|
||||
}
|
||||
return {name, {item.fileGuid, root.instanceGuid}, fb::ebx::write_document(item)};
|
||||
}
|
||||
|
||||
// What the mods are made from: the game's shared bundle, its shortest item list and an item.
|
||||
struct Game {
|
||||
fs::path root, base;
|
||||
Shared shared;
|
||||
std::size_t listIndex{};
|
||||
std::string listName;
|
||||
fb::ebx::Document list;
|
||||
std::size_t listed{};
|
||||
std::size_t itemIndex{};
|
||||
std::string itemName;
|
||||
fb::ebx::Document item;
|
||||
};
|
||||
Game read_game(const fs::path& root, const CasStore& store) {
|
||||
Game game{root, root / L"Data"};
|
||||
game.shared = shared_in(store, game.base, game.base, root);
|
||||
const auto& assets = game.shared.listing.manifest.ebx;
|
||||
bool list{}, item{};
|
||||
for (std::size_t index = 0; index < assets.size(); ++index) {
|
||||
const auto name = lower(assets[index].name);
|
||||
if (!name.starts_with("items/") || (item && !list_name(name))) continue;
|
||||
auto document = fb::ebx::read_document(read_asset(store, game.base, game.base, game.shared.listing, index, root));
|
||||
const auto type = lower(document.rootType);
|
||||
if (type == list_type) {
|
||||
const auto count = listed(document).size();
|
||||
if (list && count >= game.listed) continue;
|
||||
list = true;
|
||||
game.listIndex = index;
|
||||
game.listName = assets[index].name;
|
||||
game.listed = count;
|
||||
game.list = std::move(document);
|
||||
} else if (!item && type.find("itemasset") != std::string::npos) {
|
||||
item = true;
|
||||
game.itemIndex = index;
|
||||
game.itemName = assets[index].name;
|
||||
game.item = std::move(document);
|
||||
}
|
||||
}
|
||||
if (!list || !item) throw std::runtime_error("the game's shared bundle has no item list or no item");
|
||||
return game;
|
||||
}
|
||||
|
||||
// A cosmetic mod's folder: its copy of the shared bundle in items.toc (the game's assets, the
|
||||
// item list replaced and its items added) and one archive holding the manifest and those payloads.
|
||||
// With no list the mod leaves the game's as it is, as a mod does whose items go in another one.
|
||||
// `changed` replaces assets of the game's, by their place in the bundle.
|
||||
void write_mod(const fs::path& directory, const CasStore& store, const Game& game, const Bytes* list,
|
||||
std::initializer_list<Item> items, const std::map<std::size_t, Bytes>& changed = {}) {
|
||||
auto manifest = game.shared.listing.manifest;
|
||||
const auto& shipped = game.shared.listing.files;
|
||||
std::vector<fb::BundleFileInfo> files(shipped.begin() + static_cast<std::ptrdiff_t>(game.shared.listing.first), shipped.end());
|
||||
const auto installChunk = shipped.front().location.installChunk;
|
||||
Bytes archive;
|
||||
const auto stored = [&](std::span<const std::byte> bytes) {
|
||||
const fb::BundleFileInfo file{{true, installChunk, 1}, static_cast<std::uint32_t>(archive.size()),
|
||||
static_cast<std::uint32_t>(bytes.size())};
|
||||
archive.insert(archive.end(), bytes.begin(), bytes.end());
|
||||
return file;
|
||||
};
|
||||
const auto encoded = [](const Bytes& payload) { return fb::encode_cas(payload, {.compression = fb::CasCompression::raw}); };
|
||||
if (list) {
|
||||
manifest.ebx[game.listIndex].sha1 = sha1_of(*list);
|
||||
manifest.ebx[game.listIndex].originalSize = list->size();
|
||||
files[game.listIndex] = stored(encoded(*list));
|
||||
}
|
||||
for (const auto& [index, payload] : changed) {
|
||||
manifest.ebx[index].sha1 = sha1_of(payload);
|
||||
manifest.ebx[index].originalSize = payload.size();
|
||||
files[index] = stored(encoded(payload));
|
||||
}
|
||||
for (const auto& item : items) {
|
||||
fb::BundleAsset asset;
|
||||
asset.kind = fb::AssetKind::ebx;
|
||||
asset.name = item.name;
|
||||
asset.sha1 = sha1_of(item.payload);
|
||||
asset.originalSize = item.payload.size();
|
||||
files.insert(files.begin() + static_cast<std::ptrdiff_t>(manifest.ebx.size()), stored(encoded(item.payload)));
|
||||
manifest.ebx.push_back(std::move(asset));
|
||||
}
|
||||
files.insert(files.begin(), stored(fb::write_binary_bundle(manifest)));
|
||||
const std::vector<fb::TocBundle> bundles{{game.shared.bundle.name, fb::write_bundle_region(files), 1}};
|
||||
fs::create_directories(directory / L"Win32" / fs::path(store.directory(installChunk)));
|
||||
write_file(directory / L"Win32" / L"items.toc", fb::write_patch_toc(bundles));
|
||||
write_file(directory / L"Win32" / fs::path(store.directory(installChunk)) / L"cas_01.cas", archive);
|
||||
fs::copy_file(game.base / L"layout.toc", directory / L"layout.toc", fs::copy_options::overwrite_existing);
|
||||
}
|
||||
|
||||
// A Mods folder of its own beside nothing, merged against the installed game.
|
||||
struct Merge {
|
||||
fs::path root;
|
||||
mods::Catalog catalog;
|
||||
|
||||
Merge(const Game& game, const std::string& name) {
|
||||
wchar_t temp[MAX_PATH]{};
|
||||
GetTempPathW(MAX_PATH, temp);
|
||||
root = fs::path(temp) / ("reskate-item-lists-" + std::to_string(GetCurrentProcessId()) + "-" + name);
|
||||
fs::remove_all(root);
|
||||
catalog.data_root = game.root;
|
||||
catalog.root = root / "Mods";
|
||||
catalog.present = true;
|
||||
}
|
||||
~Merge() {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(root, ignored);
|
||||
}
|
||||
// Mods are added highest priority first.
|
||||
fs::path add(const std::string& name) {
|
||||
mods::Mod mod;
|
||||
mod.name = name;
|
||||
mod.directory = catalog.root / name;
|
||||
mod.provides_layout = true;
|
||||
catalog.mods.push_back(mod);
|
||||
return mod.directory;
|
||||
}
|
||||
};
|
||||
|
||||
// The merged shared bundle: the guid of every document in it, and what each item list names.
|
||||
struct Merged {
|
||||
std::set<fb::Guid> held;
|
||||
std::map<std::string, std::vector<fb::Guid>> lists;
|
||||
};
|
||||
Merged read_merged(const Merge& merge, const Game& game) {
|
||||
const auto output = merge.catalog.root / mods::generated_folder;
|
||||
const auto layout = vfs::read_layout(output / L"layout.toc");
|
||||
const CasStore store(game.base, merge.root / L"unused", layout.root);
|
||||
const auto shared = shared_in(store, output, game.base, game.root);
|
||||
Merged merged;
|
||||
for (std::size_t index = 0; index < shared.listing.manifest.ebx.size(); ++index) {
|
||||
const auto bytes = read_asset(store, output, game.base, shared.listing, index, game.root);
|
||||
merged.held.insert(fb::ebx::read_file_guid(bytes));
|
||||
const auto& name = shared.listing.manifest.ebx[index].name;
|
||||
if (!list_name(name)) continue;
|
||||
const auto document = fb::ebx::read_document(bytes);
|
||||
if (lower(document.rootType) == list_type) merged.lists.emplace(name, listed(document));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
bool noted(const mods::MergeReport& report, const std::string& text) {
|
||||
return std::ranges::find(report.notes, text) != report.notes.end();
|
||||
}
|
||||
bool noted_part(const mods::MergeReport& report, std::string_view part) {
|
||||
return std::ranges::any_of(report.notes, [&](const std::string& note) { return note.find(part) != std::string::npos; });
|
||||
}
|
||||
std::string describe(const mods::MergeReport& report) {
|
||||
std::string text = "issue: '" + report.issue + "'";
|
||||
for (const auto& note : report.notes) text += "\n note: " + note;
|
||||
for (const auto& [mod, problems] : report.problems)
|
||||
for (const auto& problem : problems) text += "\n " + mod + ": " + problem;
|
||||
return text;
|
||||
}
|
||||
|
||||
// What the game needs of any merge: every item every list names is in the bundle. Then how many
|
||||
// entries the mods' list ended with, and that no mod was turned away over it.
|
||||
std::vector<fb::Guid> merged_list(const Merge& merge, const Game& game, const mods::MergeReport& report,
|
||||
const std::string& label, std::size_t added) {
|
||||
expect(report.issue.empty() && report.built && report.problems.empty(),
|
||||
label + ": the merge builds the patch and keeps every mod\n" + describe(report));
|
||||
if (!report.built) return {};
|
||||
const auto merged = read_merged(merge, game);
|
||||
for (const auto& [name, items] : merged.lists)
|
||||
for (const auto& item : items)
|
||||
expect(merged.held.contains(item), label + ": " + name + " names " + item.string() +
|
||||
", which the merged bundle does not hold");
|
||||
const auto found = merged.lists.find(game.listName);
|
||||
expect(found != merged.lists.end(), label + ": the merged bundle still has " + game.listName);
|
||||
if (found == merged.lists.end()) return {};
|
||||
expect(found->second.size() == game.listed + added, label + ": the list has the game's " + std::to_string(game.listed) +
|
||||
" item(s) and " + std::to_string(added) + " more (" + std::to_string(found->second.size()) + ")\n" + describe(report));
|
||||
return found->second;
|
||||
}
|
||||
bool names(const std::vector<fb::Guid>& list, const Item& item) {
|
||||
return std::ranges::find(list, item.identity.fileGuid) != list.end();
|
||||
}
|
||||
|
||||
constexpr char same_name[] = "items/reskate_test/own_same_name";
|
||||
|
||||
// Two mods add different items under one name. The higher-priority mod's is the one the bundle
|
||||
// keeps, so the other's entry goes, and the merge says which mod lost an item to which.
|
||||
void same_name_from_two_mods(const Game& game, const CasStore& store, const fs::path* keep) {
|
||||
Merge merge(game, "same-name");
|
||||
const auto upper = item_from(game.item, same_name, 0x10);
|
||||
const auto under = item_from(game.item, same_name, 0x20);
|
||||
const auto upperList = list_with(game.list, {upper.identity}), underList = list_with(game.list, {under.identity});
|
||||
const auto build = [&](const fs::path& top, const fs::path& bottom) {
|
||||
write_mod(top, store, game, &upperList, {upper});
|
||||
write_mod(bottom, store, game, &underList, {under});
|
||||
};
|
||||
const auto top = merge.add("upper"), bottom = merge.add("under");
|
||||
build(top, bottom);
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
const auto list = merged_list(merge, game, report, "same name", 1);
|
||||
expect(names(list, upper) && !names(list, under), "same name: the list keeps the higher-priority mod's item only");
|
||||
expect(noted(report, "under: " + game.shared.bundle.name + ": 1 added asset(s) share a name with ones upper adds, e.g. " +
|
||||
same_name + "; the merged bundle keeps upper's"),
|
||||
"same name: the merge says whose asset the name went to\n" + describe(report));
|
||||
expect(noted(report, "under: " + game.listName + ": 1 item(s) left out of the list because the merged bundle does not "
|
||||
"hold them, e.g. " + same_name + ", replaced by upper's asset of the same name"),
|
||||
"same name: the merge says which item was left out and why\n" + describe(report));
|
||||
if (!keep) return;
|
||||
// The same two for the game's own Mods folder, where a mod has to say what it was built for.
|
||||
const std::string stamp = "ReSkate Studio native Patch v1\nskate_sha256=" + std::string(supported_build::game_sha256) + "\n";
|
||||
for (const auto* name : {L"ReSkateTest-SameNameUpper", L"ReSkateTest-SameNameUnder"}) fs::create_directories(*keep / name);
|
||||
build(*keep / L"ReSkateTest-SameNameUpper", *keep / L"ReSkateTest-SameNameUnder");
|
||||
for (const auto* name : {L"ReSkateTest-SameNameUpper", L"ReSkateTest-SameNameUnder"})
|
||||
write_file(*keep / name / mods::studio_marker_file, std::as_bytes(std::span(stamp)));
|
||||
std::cout << "The two same-name mods are in " << keep->string() << ".\n";
|
||||
}
|
||||
|
||||
// Two mods add items under names of their own: both stay, and nothing is said about leaving any out.
|
||||
void different_names_from_two_mods(const Game& game, const CasStore& store) {
|
||||
Merge merge(game, "different-names");
|
||||
const auto first = item_from(game.item, "items/reskate_test/own_first", 0x10);
|
||||
const auto second = item_from(game.item, "items/reskate_test/own_second", 0x20);
|
||||
const auto firstList = list_with(game.list, {first.identity}), secondList = list_with(game.list, {second.identity});
|
||||
write_mod(merge.add("first"), store, game, &firstList, {first});
|
||||
write_mod(merge.add("second"), store, game, &secondList, {second});
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
const auto list = merged_list(merge, game, report, "different names", 2);
|
||||
expect(names(list, first) && names(list, second), "different names: the list has both mods' items");
|
||||
expect(!noted_part(report, "left out of the list") && !noted_part(report, "share a name"),
|
||||
"different names: nothing is reported\n" + describe(report));
|
||||
}
|
||||
|
||||
// One mod alone changes the list, beside a mod that ships the bundle with the game's. Its list
|
||||
// as it wrote it is what the bundle gets, unless that list names an item nothing ships: then
|
||||
// that entry goes and its own item stays.
|
||||
void one_mod_changes_the_list(const Game& game, const CasStore& store, bool missing) {
|
||||
const std::string label = missing ? "an item nothing ships" : "one mod's list";
|
||||
Merge merge(game, missing ? "missing-item" : "one-list");
|
||||
const auto own = item_from(game.item, "items/reskate_test/own_alone", 0x10);
|
||||
const fb::ebx::ImportReference absent{guid(0x70), guid(0x90)};
|
||||
const auto shipped = missing ? list_with(game.list, {own.identity, absent}) : list_with(game.list, {own.identity});
|
||||
write_mod(merge.add("alone"), store, game, &shipped, {own});
|
||||
write_mod(merge.add("bystander"), store, game, nullptr, {});
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
const auto list = merged_list(merge, game, report, label, 1);
|
||||
expect(names(list, own), label + ": the list has the mod's own item");
|
||||
const auto said = noted(report, "alone: " + game.listName + ": 1 item(s) left out of the list because the merged bundle "
|
||||
"does not hold them, e.g. the asset with guid " + absent.fileGuid.string() +
|
||||
", which no enabled mod adds to the bundle");
|
||||
expect(said == missing, label + (missing ? ": the merge says which item was left out\n" : ": nothing is reported\n") +
|
||||
describe(report));
|
||||
}
|
||||
|
||||
// Two packs that both carry one item, each with an item of its own besides. Both list the shared
|
||||
// one, and it is one item: the merged list has it once.
|
||||
void an_item_two_mods_both_list(const Game& game, const CasStore& store) {
|
||||
Merge merge(game, "listed-twice");
|
||||
const auto both = item_from(game.item, "items/reskate_test/own_in_both", 0x10);
|
||||
const auto first = item_from(game.item, "items/reskate_test/own_first", 0x20);
|
||||
const auto second = item_from(game.item, "items/reskate_test/own_second", 0x30);
|
||||
const auto firstList = list_with(game.list, {both.identity, first.identity});
|
||||
const auto secondList = list_with(game.list, {both.identity, second.identity});
|
||||
write_mod(merge.add("first"), store, game, &firstList, {both, first});
|
||||
write_mod(merge.add("second"), store, game, &secondList, {both, second});
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
const auto list = merged_list(merge, game, report, "listed by both", 3);
|
||||
expect(names(list, both) && names(list, first) && names(list, second), "listed by both: the list has all three items");
|
||||
expect(std::ranges::count(list, both.identity.fileGuid) == 1, "listed by both: the item both mods list is in it once");
|
||||
expect(noted(report, game.listName + ": combined 2 edits (0 instance(s), 3 list entries, 1 repeated entry(ies) listed once)"),
|
||||
"listed by both: the merge says one entry was a repeat\n" + describe(report));
|
||||
}
|
||||
|
||||
// The game's item as a mod that retunes it ships it: the same document, changed in place.
|
||||
Bytes item_with(const fb::ebx::Document& game, const std::function<void(fb::ebx::Object&)>& change) {
|
||||
auto item = fb::ebx::detail::clone_document(game);
|
||||
change(*item.instances.front().object);
|
||||
return fb::ebx::write_document(item);
|
||||
}
|
||||
fb::ebx::Value& field(fb::ebx::Object& object, std::string_view name) {
|
||||
for (auto& entry : object.fields)
|
||||
if (entry.name == name) return entry.value;
|
||||
throw std::runtime_error("the game's item has no " + std::string(name));
|
||||
}
|
||||
void set_hash(fb::ebx::Object& object, std::uint32_t hash) {
|
||||
auto& value = field(object, "HashedAssetKey");
|
||||
if (std::holds_alternative<std::int64_t>(value.data)) value.data = static_cast<std::int64_t>(hash);
|
||||
else value.data = static_cast<std::uint64_t>(hash);
|
||||
}
|
||||
std::uint32_t hash_of(const fb::ebx::Object& object) {
|
||||
const auto& value = field(const_cast<fb::ebx::Object&>(object), "HashedAssetKey");
|
||||
const auto* number = std::get_if<std::int64_t>(&value.data);
|
||||
return number ? static_cast<std::uint32_t>(*number) : static_cast<std::uint32_t>(std::get<std::uint64_t>(value.data));
|
||||
}
|
||||
// A change the merge cannot take value by value: the first list of the item made one shorter,
|
||||
// or failing that its first reference cleared. False when the item has neither.
|
||||
bool reshape(fb::ebx::Object& object) {
|
||||
for (auto& entry : object.fields)
|
||||
if (auto* list = std::get_if<fb::ebx::Value::Array>(&entry.value.data); list && !list->empty()) {
|
||||
list->pop_back();
|
||||
return true;
|
||||
}
|
||||
for (auto& entry : object.fields)
|
||||
if (auto* pointer = std::get_if<fb::ebx::PointerReference>(&entry.value.data);
|
||||
pointer && pointer->kind != fb::ebx::PointerKind::null) {
|
||||
*pointer = {};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The merged shared bundle's copy of the game's item, and the sha1 the bundle lists it under.
|
||||
std::pair<fb::ebx::Document, fb::Sha1> merged_item(const Merge& merge, const Game& game) {
|
||||
const auto output = merge.catalog.root / mods::generated_folder;
|
||||
const auto layout = vfs::read_layout(output / L"layout.toc");
|
||||
const CasStore store(game.base, merge.root / L"unused", layout.root);
|
||||
const auto shared = shared_in(store, output, game.base, game.root);
|
||||
const auto& assets = shared.listing.manifest.ebx;
|
||||
for (std::size_t index = 0; index < assets.size(); ++index)
|
||||
if (assets[index].name == game.itemName)
|
||||
return {fb::ebx::read_document(read_asset(store, output, game.base, shared.listing, index, game.root)), assets[index].sha1};
|
||||
throw std::runtime_error("the merged bundle has no " + game.itemName);
|
||||
}
|
||||
|
||||
// Two mods each ship the game's item with values of their own changed in place. Nothing was added
|
||||
// to it, so the merge has no list to put together: it has the values. Each mod's are in the
|
||||
// merged item, and where both changed one the higher mod's is.
|
||||
void values_two_mods_changed(const Game& game, const CasStore& store, bool contested) {
|
||||
const std::string label = contested ? "one value from two mods" : "values from two mods";
|
||||
Merge merge(game, contested ? "one-value" : "two-values");
|
||||
const auto upper = item_with(game.item, [&](fb::ebx::Object& root) {
|
||||
if (contested) set_hash(root, 0x1111);
|
||||
else field(root, "Key").data = std::string("reskate_upper");
|
||||
});
|
||||
const auto under = item_with(game.item, [&](fb::ebx::Object& root) {
|
||||
set_hash(root, 0x2222);
|
||||
if (contested) field(root, "Key").data = std::string("reskate_under");
|
||||
});
|
||||
write_mod(merge.add("upper"), store, game, nullptr, {}, {{game.itemIndex, upper}});
|
||||
write_mod(merge.add("under"), store, game, nullptr, {}, {{game.itemIndex, under}});
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
expect(report.issue.empty() && report.built && report.problems.empty(),
|
||||
label + ": the merge builds the patch and keeps every mod\n" + describe(report));
|
||||
if (!report.built) return;
|
||||
const auto item = merged_item(merge, game).first;
|
||||
auto& root = *item.instances.front().object;
|
||||
expect(std::get<std::string>(field(root, "Key").data) == (contested ? "reskate_under" : "reskate_upper"),
|
||||
label + ": the merged item has the key one mod changed");
|
||||
expect(hash_of(root) == (contested ? 0x1111u : 0x2222u),
|
||||
label + (contested ? ": the value both changed is the higher mod's" : ": and the value the other mod changed"));
|
||||
expect(noted(report, game.itemName + ": combined 2 edits (0 instance(s), 0 list entries, " +
|
||||
(contested ? "3 changed value(s), 1 disagreed)" : "2 changed value(s))")),
|
||||
label + ": the merge says what it combined\n" + describe(report));
|
||||
}
|
||||
|
||||
// One of the two also changed the item in a way that is not a value (a list made shorter). That
|
||||
// cannot be put together with the other mod's values, so the higher mod's copy stays whole, as
|
||||
// it always did, and the merge now says whose changes are not in the game.
|
||||
void a_change_that_does_not_combine(const Game& game, const CasStore& store) {
|
||||
Merge merge(game, "not-combined");
|
||||
bool reshaped{};
|
||||
const auto upper = item_with(game.item, [&](fb::ebx::Object& root) {
|
||||
field(root, "Key").data = std::string("reskate_upper");
|
||||
reshaped = reshape(root);
|
||||
});
|
||||
if (!reshaped) {
|
||||
std::cout << "The game's item has no list or reference to change; that case is skipped.\n";
|
||||
return;
|
||||
}
|
||||
const auto under = item_with(game.item, [&](fb::ebx::Object& root) { set_hash(root, 0x2222); });
|
||||
write_mod(merge.add("upper"), store, game, nullptr, {}, {{game.itemIndex, upper}});
|
||||
write_mod(merge.add("under"), store, game, nullptr, {}, {{game.itemIndex, under}});
|
||||
const auto report = mods::merge_mods(merge.catalog);
|
||||
expect(report.issue.empty() && report.built && report.problems.empty(),
|
||||
"not combined: the merge builds the patch and keeps every mod\n" + describe(report));
|
||||
if (!report.built) return;
|
||||
expect(merged_item(merge, game).second == sha1_of(upper), "not combined: the bundle has the higher mod's copy as that mod built it");
|
||||
expect(noted(report, game.itemName + ": kept upper's copy whole; the changes under made to it are of a kind that cannot "
|
||||
"be combined with it and are not in the game"),
|
||||
"not combined: the merge says whose changes are missing\n" + describe(report));
|
||||
}
|
||||
|
||||
// What other mods' copies of the bundle are given (a map carries one for its levels): one list
|
||||
// for all of them, put together from every mod's. Under a name two mods share, such a copy gets
|
||||
// the higher mod's asset, so the lower mod's entry for its own has to be out of that list too.
|
||||
void the_list_other_copies_are_given(const Game& game, const CasStore& store) {
|
||||
Merge merge(game, "carried-list");
|
||||
const auto upper = item_from(game.item, same_name, 0x10);
|
||||
const auto under = item_from(game.item, same_name, 0x20);
|
||||
const auto upperList = list_with(game.list, {upper.identity}), underList = list_with(game.list, {under.identity});
|
||||
write_mod(merge.add("upper"), store, game, &upperList, {upper});
|
||||
write_mod(merge.add("under"), store, game, &underList, {under});
|
||||
std::vector<const mods::Mod*> order;
|
||||
std::map<const mods::Mod*, RelativeFiles> files;
|
||||
for (const auto& mod : merge.catalog.mods) {
|
||||
order.push_back(&mod);
|
||||
files.emplace(&mod, scan(mod.directory));
|
||||
}
|
||||
mods::MergeReport report;
|
||||
const auto overrides = collect_asset_overrides(order, files, store, game.base, game.root, report);
|
||||
const auto versions = overrides.changed.find(lower(game.listName));
|
||||
expect(versions != overrides.changed.end() && versions->second.size() == 1,
|
||||
"carried list: there is one changed list for other mods' copies\n" + describe(report));
|
||||
if (versions == overrides.changed.end() || versions->second.empty()) return;
|
||||
const auto& change = versions->second.begin()->second;
|
||||
const auto list = listed(fb::ebx::read_document(fb::decode_cas(change.encoded, {game.root})));
|
||||
expect(list.size() == game.listed + 1 && names(list, upper) && !names(list, under),
|
||||
"carried list: it has the game's items and the higher mod's, not the one no copy is given (" +
|
||||
std::to_string(list.size()) + " entries)");
|
||||
expect(change.names == std::set<fb::Guid>{upper.identity.fileGuid}, "carried list: the item that goes with it is the higher mod's");
|
||||
expect(noted(report, "under: " + game.listName + ": 1 entry(ies) left out of the copies other mods carry, where another "
|
||||
"mod's asset of the same name stands in for what they name"),
|
||||
"carried list: the merge says which mod's entry was left out\n" + describe(report));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) try {
|
||||
if (argc < 2 || !fs::exists(fs::path(argv[1]) / L"Data" / L"Win32" / L"items.toc")) {
|
||||
std::cout << "No game given; skipped.\n";
|
||||
return 0;
|
||||
}
|
||||
const fs::path root = argv[1];
|
||||
const auto layout = vfs::read_layout(root / L"Data" / L"layout.toc");
|
||||
const CasStore store(root / L"Data", fs::temp_directory_path() / L"reskate-item-lists-unused", layout.root);
|
||||
const auto game = read_game(root, store);
|
||||
const fs::path keep = argc > 2 ? fs::path(argv[2]) : fs::path{};
|
||||
same_name_from_two_mods(game, store, argc > 2 ? &keep : nullptr);
|
||||
different_names_from_two_mods(game, store);
|
||||
one_mod_changes_the_list(game, store, false);
|
||||
one_mod_changes_the_list(game, store, true);
|
||||
an_item_two_mods_both_list(game, store);
|
||||
values_two_mods_changed(game, store, false);
|
||||
values_two_mods_changed(game, store, true);
|
||||
a_change_that_does_not_combine(game, store);
|
||||
the_list_other_copies_are_given(game, store);
|
||||
if (failures) {
|
||||
std::cerr << failures << " failure(s)\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "item list merge: ok (" << game.listName << ", " << game.listed << " item(s) in the game)\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Assets a mod ADDS to a bundle (new songs, say) while another mod ships its own copy of that
|
||||
// bundle (a map does, for its levels). The merged bundle is built from one mod's copy, so
|
||||
// without the carrying step the added assets are missing from it whenever the map's copy wins.
|
||||
//
|
||||
// The fixture is a small made-up game and two mods in a temp folder, written with the repo's
|
||||
// own writers, so it needs no game files:
|
||||
// game bundle "win32/test/shared": test/playlist, test/other
|
||||
// bundle "win32/test/second": test/playlist again, test/extra
|
||||
// map a level mod: its own level superbundle (a new TOC) with a copy of that bundle, as a
|
||||
// custom map carries the core assets for its level; it edits nothing
|
||||
// music an asset mod that changes test/playlist to name a new song, and adds the song, the
|
||||
// wave the song names, the wave's sound-bank resource, and an asset nothing names.
|
||||
#include "Engine/Resource/binary_bundle.h"
|
||||
#include "Engine/Resource/cas_codec.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
#include "Engine/Resource/toc.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Engine/Vfs/native_db.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
namespace fb = dingosdk::frostbite;
|
||||
namespace db = dingosdk::native_db;
|
||||
using namespace dingosdk;
|
||||
|
||||
namespace {
|
||||
using Bytes = std::vector<std::byte>;
|
||||
constexpr std::uint32_t package = 0x1234;
|
||||
constexpr char bundle_name[] = "win32/test/shared";
|
||||
constexpr char second_bundle[] = "win32/test/second";
|
||||
|
||||
int failures = 0;
|
||||
void expect(bool ok, const std::string& what) {
|
||||
if (ok) return;
|
||||
std::cerr << "FAIL: " << what << '\n';
|
||||
++failures;
|
||||
}
|
||||
|
||||
// ---- Bytes ---------------------------------------------------------------------------------------
|
||||
void put32(Bytes& out, std::uint32_t value) {
|
||||
for (int shift = 0; shift < 32; shift += 8) out.push_back(static_cast<std::byte>(value >> shift));
|
||||
}
|
||||
void put_bytes(Bytes& out, std::string_view text) {
|
||||
for (const char c : text) out.push_back(static_cast<std::byte>(c));
|
||||
}
|
||||
void put_guid(Bytes& out, const fb::Guid& guid) { out.insert(out.end(), guid.bytes.begin(), guid.bytes.end()); }
|
||||
fb::Guid guid(std::uint8_t seed) {
|
||||
fb::Guid value;
|
||||
value.bytes.fill(static_cast<std::byte>(seed));
|
||||
return value;
|
||||
}
|
||||
void write(const fs::path& path, std::span<const std::byte> bytes) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::binary).write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
Bytes read(const fs::path& path) {
|
||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||||
Bytes bytes(static_cast<std::size_t>(in.tellg()));
|
||||
in.seekg(0);
|
||||
in.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ---- An EBX document with no instances: a file GUID and the files it imports. -----------------
|
||||
// That is all the merge reads from an asset to follow what it refers to.
|
||||
void chunk(Bytes& out, const char (&id)[5], const Bytes& body) {
|
||||
put_bytes(out, std::string_view(id, 4));
|
||||
put32(out, static_cast<std::uint32_t>(body.size()));
|
||||
out.insert(out.end(), body.begin(), body.end());
|
||||
if (body.size() & 1) out.push_back(std::byte{0});
|
||||
}
|
||||
Bytes ebx_document(const fb::Guid& file, const std::vector<fb::Guid>& imports) {
|
||||
Bytes fixup;
|
||||
put_guid(fixup, file);
|
||||
for (int i = 0; i < 6; ++i) put32(fixup, 0); // types, signatures, exported, instances, pointers, resource refs
|
||||
put32(fixup, static_cast<std::uint32_t>(imports.size()));
|
||||
for (const auto& import : imports) {
|
||||
put_guid(fixup, import);
|
||||
put_guid(fixup, guid(0xEE)); // the imported class
|
||||
}
|
||||
for (int i = 0; i < 2; ++i) put32(fixup, 0); // import relocations, type-info references
|
||||
for (int i = 0; i < 3; ++i) put32(fixup, 0); // array, boxed-value and string sections
|
||||
const Bytes data(16);
|
||||
Bytes extra;
|
||||
put32(extra, 0); // arrays
|
||||
put32(extra, 0); // boxed values
|
||||
Bytes reflection;
|
||||
for (int i = 0; i < 5; ++i) put32(reflection, 0); // signatures, types, fields, groups, mappings
|
||||
Bytes body;
|
||||
put_bytes(body, std::string_view("EBX\0", 4));
|
||||
chunk(body, "EFIX", fixup);
|
||||
chunk(body, "EBXD", data);
|
||||
chunk(body, "EBXX", extra);
|
||||
chunk(body, "REFL", reflection);
|
||||
Bytes riff;
|
||||
put_bytes(riff, "RIFF");
|
||||
put32(riff, static_cast<std::uint32_t>(body.size()));
|
||||
riff.insert(riff.end(), body.begin(), body.end());
|
||||
return riff;
|
||||
}
|
||||
|
||||
// ---- The fixture's files -----------------------------------------------------------------------
|
||||
// layout.toc with one install chunk, `package`, whose archives live in Win32/pkg.
|
||||
Bytes layout_toc(const std::vector<std::string>& extra = {}) {
|
||||
const auto leaf = [](unsigned type, const char* name, std::vector<unsigned char> payload) {
|
||||
db::Node node;
|
||||
node.type = type;
|
||||
node.named = true;
|
||||
node.name = name;
|
||||
node.owned = std::move(payload);
|
||||
return node;
|
||||
};
|
||||
const auto container = [](unsigned type, const char* name, std::vector<db::Node> children) {
|
||||
db::Node node;
|
||||
node.type = type;
|
||||
node.named = *name != '\0';
|
||||
node.name = name;
|
||||
node.terminated = true;
|
||||
node.children = std::move(children);
|
||||
return node;
|
||||
};
|
||||
const auto records = [](const std::vector<std::string>& names) {
|
||||
std::vector<db::Node> rows;
|
||||
for (const auto& name : names) rows.push_back(db::make_named_record("name", name));
|
||||
return rows;
|
||||
};
|
||||
const auto low = static_cast<unsigned char>(package & 0xFF), high = static_cast<unsigned char>(package >> 8);
|
||||
const auto chunk_entry = container(2, "", {db::make_named_record("name", "pkg").children.front(),
|
||||
leaf(8, "persistentIndex", {low, high, 0, 0}),
|
||||
container(1, "superbundles", records(extra))});
|
||||
const auto root = container(2, "", {
|
||||
container(1, "superBundles", [&] { auto rows = records({"Win32/globals"}); for (auto& row : records(extra)) rows.push_back(std::move(row)); return rows; }()),
|
||||
container(2, "installManifest", {container(1, "installChunks", {chunk_entry})}),
|
||||
leaf(19, "layeredInstallChunkFiles", {1, 0, low, high, 0, 0, 0, 0})});
|
||||
const auto tree = db::write(root);
|
||||
Bytes file(db::envelope_size + tree.size());
|
||||
std::memcpy(file.data(), db::magic, sizeof(db::magic));
|
||||
std::memcpy(file.data() + db::envelope_size, tree.data(), tree.size());
|
||||
return file;
|
||||
}
|
||||
|
||||
Bytes encoded(const Bytes& payload) { return fb::encode_cas(payload, {.compression = fb::CasCompression::raw}); }
|
||||
|
||||
fb::Sha1 sha(const std::string& name, unsigned version) {
|
||||
fb::Sha1 value;
|
||||
for (std::size_t i = 0; i < name.size(); ++i) value.bytes[i % 19] ^= static_cast<std::byte>(name[i]);
|
||||
value.bytes[19] = static_cast<std::byte>(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
struct Ebx { std::string name; unsigned version; Bytes payload; };
|
||||
struct Resource { std::string name; Bytes payload; };
|
||||
|
||||
fb::BinaryBundle manifest_of(const std::vector<Ebx>& assets, const std::vector<Resource>& resources) {
|
||||
fb::BinaryBundle manifest;
|
||||
for (const auto& asset : assets) {
|
||||
fb::BundleAsset entry;
|
||||
entry.kind = fb::AssetKind::ebx;
|
||||
entry.name = asset.name;
|
||||
entry.sha1 = sha(asset.name, asset.version);
|
||||
entry.originalSize = asset.payload.size();
|
||||
manifest.ebx.push_back(std::move(entry));
|
||||
}
|
||||
for (const auto& resource : resources) {
|
||||
fb::BundleAsset entry;
|
||||
entry.kind = fb::AssetKind::resource;
|
||||
entry.name = resource.name;
|
||||
entry.sha1 = sha(resource.name, 0);
|
||||
entry.resourceId = 7;
|
||||
entry.resourceType = 0xb2c465f6;
|
||||
entry.resourceMeta.assign(16, std::byte{0});
|
||||
entry.originalSize = resource.payload.size();
|
||||
manifest.resources.push_back(std::move(entry));
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
fs::path root;
|
||||
mods::Catalog catalog;
|
||||
|
||||
explicit Fixture(const std::string& name) {
|
||||
wchar_t temp[MAX_PATH]{};
|
||||
GetTempPathW(MAX_PATH, temp);
|
||||
root = fs::path(temp) / ("reskate-merge-added-" + std::to_string(GetCurrentProcessId()) + "-" + name);
|
||||
fs::remove_all(root);
|
||||
catalog.data_root = root / "game";
|
||||
catalog.root = catalog.data_root / "Mods";
|
||||
catalog.present = true;
|
||||
// The game: two assets in one bundle, and a second bundle that has the playlist as well
|
||||
// (the game keeps a list in several bundles), stored in the game's own archive.
|
||||
const std::vector<Ebx> assets{{"test/playlist", 0, ebx_document(guid(1), {})}, {"test/other", 0, ebx_document(guid(2), {})}};
|
||||
const std::vector<Ebx> second{{"test/playlist", 0, ebx_document(guid(1), {})}, {"test/extra", 0, ebx_document(guid(3), {})}};
|
||||
Bytes archive;
|
||||
const auto region = [&](const std::vector<Ebx>& list) {
|
||||
// The asset list is the region's first file, as it is in every bundle of the game:
|
||||
// a bundle one mod alone ships then passes through as that mod built it.
|
||||
const auto listing = fb::write_binary_bundle(manifest_of(list, {}));
|
||||
std::vector<fb::BundleFileInfo> files{{{false, package, 1}, static_cast<std::uint32_t>(archive.size()),
|
||||
static_cast<std::uint32_t>(listing.size())}};
|
||||
archive.insert(archive.end(), listing.begin(), listing.end());
|
||||
for (const auto& asset : list) {
|
||||
const auto payload = encoded(asset.payload);
|
||||
files.push_back({{false, package, 1}, static_cast<std::uint32_t>(archive.size()), static_cast<std::uint32_t>(payload.size())});
|
||||
archive.insert(archive.end(), payload.begin(), payload.end());
|
||||
}
|
||||
return fb::write_bundle_region(files);
|
||||
};
|
||||
const std::vector<fb::TocBundle> bundles{{bundle_name, region(assets), 1}, {second_bundle, region(second), 1}};
|
||||
write(catalog.data_root / "Data" / "layout.toc", layout_toc());
|
||||
write(catalog.data_root / "Data" / "Win32" / "test_shared.toc", fb::write_patch_toc(bundles));
|
||||
write(catalog.data_root / "Data" / "Win32" / "pkg" / "cas_01.cas", archive);
|
||||
}
|
||||
~Fixture() {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(root, ignored);
|
||||
}
|
||||
|
||||
// A mod with its own copy of a bundle: the manifest first (raw), then every payload.
|
||||
void add(const std::string& name, bool levels, const std::string& toc, const std::vector<std::string>& superbundles,
|
||||
const std::vector<Ebx>& assets, const std::vector<Resource>& resources = {}, const char* bundle = bundle_name) {
|
||||
const auto listing = fb::write_binary_bundle(manifest_of(assets, resources));
|
||||
Bytes archive(listing);
|
||||
std::vector<fb::BundleFileInfo> files{{{true, package, 1}, 0, static_cast<std::uint32_t>(listing.size())}};
|
||||
const auto store = [&](const Bytes& payload) {
|
||||
const auto bytes = encoded(payload);
|
||||
files.push_back({{true, package, 1}, static_cast<std::uint32_t>(archive.size()), static_cast<std::uint32_t>(bytes.size())});
|
||||
archive.insert(archive.end(), bytes.begin(), bytes.end());
|
||||
};
|
||||
for (const auto& asset : assets) store(asset.payload);
|
||||
for (const auto& resource : resources) store(resource.payload);
|
||||
const std::vector<fb::TocBundle> bundles{{bundle, fb::write_bundle_region(files), 1}};
|
||||
const auto directory = catalog.root / name;
|
||||
write(directory / "layout.toc", layout_toc(superbundles));
|
||||
write(directory / "Win32" / fs::path(toc), fb::write_patch_toc(bundles));
|
||||
write(directory / "Win32" / "pkg" / "cas_01.cas", archive);
|
||||
mods::Mod mod;
|
||||
mod.name = name;
|
||||
mod.directory = directory;
|
||||
mod.provides_layout = true;
|
||||
mod.provides_levels = levels;
|
||||
catalog.mods.push_back(std::move(mod));
|
||||
}
|
||||
|
||||
// How many files the merged copy of a bundle in `toc` lists, or -1 when the merge left none.
|
||||
int merged_files(const std::string& relative, const std::string& name = bundle_name) const {
|
||||
const auto toc = catalog.root / mods::generated_folder / "Win32" / fs::path(relative);
|
||||
if (!fs::exists(toc)) return -1;
|
||||
for (const auto& bundle : fb::read_toc(read(toc)).bundles)
|
||||
if (bundle.name == name) return static_cast<int>(fb::read_bundle_region(bundle.region).files.size());
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
// The game's copy of the bundle, as a level mod carries it.
|
||||
std::vector<Ebx> game_copy() {
|
||||
return {{"test/playlist", 0, ebx_document(guid(1), {})}, {"test/other", 0, ebx_document(guid(2), {})}};
|
||||
}
|
||||
// The music mod's bundle: the playlist now names the song (a change), the song names its wave,
|
||||
// and one more asset nothing names.
|
||||
std::vector<Ebx> music_assets() {
|
||||
return {{"test/playlist", 1, ebx_document(guid(1), {guid(10)})},
|
||||
{"test/other", 0, ebx_document(guid(2), {})},
|
||||
{"test/song", 0, ebx_document(guid(10), {guid(11)})},
|
||||
{"test/wave", 0, ebx_document(guid(11), {})},
|
||||
{"test/unrelated", 0, ebx_document(guid(12), {})}};
|
||||
}
|
||||
std::vector<Resource> music_resources() { return {{"test/wave", {std::byte{1}, std::byte{2}, std::byte{3}}}}; }
|
||||
|
||||
bool noted(const mods::MergeReport& report, const std::string& text) {
|
||||
for (const auto& note : report.notes)
|
||||
if (note == text) return true;
|
||||
return false;
|
||||
}
|
||||
std::string describe(const mods::MergeReport& report) {
|
||||
std::string text = "issue: '" + report.issue + "'";
|
||||
for (const auto& note : report.notes) text += "\n note: " + note;
|
||||
for (const auto& [mod, problems] : report.problems)
|
||||
for (const auto& problem : problems) text += "\n " + mod + ": " + problem;
|
||||
return text;
|
||||
}
|
||||
|
||||
constexpr char shared_toc[] = "test_shared.toc";
|
||||
constexpr char map_toc[] = "levels/test/map.toc";
|
||||
constexpr char map_superbundle[] = "Win32/levels/test/map";
|
||||
|
||||
// The level mod alone: what its copy of the bundle holds with nothing carried into it.
|
||||
int map_only() {
|
||||
Fixture fixture("map-only");
|
||||
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, "map only: the merge builds the patch\n" + describe(report));
|
||||
return fixture.merged_files(map_toc);
|
||||
}
|
||||
|
||||
// The music mod edits the game's TOC; the map's copy of the bundle is in its own TOC, so nothing
|
||||
// of the music mod reaches it unless the merge carries it. The playlist change always did (it is a
|
||||
// change to an asset the map's copy has); the song, its wave and the wave's resource are what
|
||||
// the changed playlist leads to. The asset nothing names must stay out. Either mod may come first.
|
||||
void carried_into_the_maps_copy(bool map_first, int baseline) {
|
||||
const std::string order = map_first ? "map first" : "music first";
|
||||
Fixture fixture(map_first ? "map-first" : "music-first");
|
||||
const auto add_map = [&] { fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); };
|
||||
const auto add_music = [&] { fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); };
|
||||
if (map_first) { add_map(); add_music(); } else { add_music(); add_map(); }
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, order + ": the merge builds the patch\n" + describe(report));
|
||||
expect(noted(report, std::string("map: ") + bundle_name + ": 3 asset(s) added by other mods, e.g. test/song"),
|
||||
order + ": the map's copy receives them\n" + describe(report));
|
||||
const auto files = fixture.merged_files(map_toc);
|
||||
expect(files == baseline + 3, order + ": the map's copy gained those three files and not the unnamed asset (" +
|
||||
std::to_string(files) + " files, " + std::to_string(baseline) + " without the music mod)");
|
||||
}
|
||||
|
||||
// Another asset mod ships the game's copy of the bundle in the TOC the music mod ships, the way
|
||||
// cosmetic mods share one. That copy merges with the music mod's bundle, which already has the
|
||||
// song, so nothing is carried into it (no "added by other mods" note), and the merged bundle is
|
||||
// exactly the union: the manifest, the music mod's five EBX and its resource.
|
||||
void copy_in_the_adders_toc_is_left_alone() {
|
||||
Fixture fixture("same-toc");
|
||||
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
|
||||
fixture.add("other", false, shared_toc, {}, game_copy());
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, "same TOC: the merge builds the patch\n" + describe(report));
|
||||
for (const auto& note : report.notes)
|
||||
expect(note.find("added by other mods") == std::string::npos,
|
||||
"same TOC: nothing is carried into a copy in the adder's own TOC: " + note);
|
||||
const auto expected = static_cast<int>(1 + music_assets().size() + music_resources().size());
|
||||
const auto files = fixture.merged_files(shared_toc);
|
||||
expect(files == expected, "same TOC: the merged bundle is the union, " + std::to_string(expected) + " files (" +
|
||||
std::to_string(files) + " files)");
|
||||
}
|
||||
// Two asset mods each add test/song, each named by a change of its own (the rival's is to the
|
||||
// other asset). Only one can be carried into the map's copy: the higher-priority mod's. When the
|
||||
// two are different assets the merge says whose the name went to; the same asset added by both
|
||||
// is no clash and is not reported. The bundle the two mods share keeps one asset per name as
|
||||
// well, and says so the same way.
|
||||
void same_name_from_two_mods(bool different) {
|
||||
const std::string label = different ? "name clash" : "same asset twice";
|
||||
Fixture fixture(different ? "name-clash" : "same-twice");
|
||||
const auto song = guid(different ? 20 : 10);
|
||||
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
|
||||
fixture.add("rival", false, shared_toc, {},
|
||||
{{"test/playlist", 0, ebx_document(guid(1), {})},
|
||||
{"test/other", 1, ebx_document(guid(2), {song})},
|
||||
{"test/song", different ? 1u : 0u, ebx_document(song, {})}});
|
||||
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, label + ": the merge builds the patch\n" + describe(report));
|
||||
const bool said = noted(report, "rival: 1 added asset(s) share a name with ones music adds, e.g. test/song; "
|
||||
"other mods' copies of the bundle get music's");
|
||||
expect(said == different, label + (different ? ": the merge says whose asset the name went to\n"
|
||||
: ": nothing is reported\n") + describe(report));
|
||||
const bool kept = noted(report, std::string("rival: ") + bundle_name + ": 1 added asset(s) share a name with ones "
|
||||
"music adds, e.g. test/song; the merged bundle keeps music's");
|
||||
expect(kept == different, label + (different ? ": the merge says whose asset the shared bundle keeps\n"
|
||||
: ": nothing is reported about the shared bundle\n") + describe(report));
|
||||
expect(noted(report, std::string("map: ") + bundle_name + ": 3 asset(s) added by other mods, e.g. test/song"),
|
||||
label + ": the map's copy still receives the first mod's three\n" + describe(report));
|
||||
}
|
||||
|
||||
// Two music mods both change test/playlist to add their own songs. Both mods' songs,
|
||||
// waves and resources must be carried into the map's copy of the bundle, not just the first mod's.
|
||||
void two_music_mods_both_carried_into_maps_copy(int baseline) {
|
||||
Fixture fixture("two-music-mods");
|
||||
const auto song1 = guid(10), wave1 = guid(11);
|
||||
const auto song2 = guid(20), wave2 = guid(21);
|
||||
fixture.add("music1", false, shared_toc, {},
|
||||
{{"test/playlist", 1, ebx_document(guid(1), {song1})},
|
||||
{"test/other", 0, ebx_document(guid(2), {})},
|
||||
{"test/song1", 0, ebx_document(song1, {wave1})},
|
||||
{"test/wave1", 0, ebx_document(wave1, {})}},
|
||||
{{"test/wave1", {std::byte{1}}}});
|
||||
fixture.add("music2", false, shared_toc, {},
|
||||
{{"test/playlist", 2, ebx_document(guid(1), {song2})},
|
||||
{"test/other", 0, ebx_document(guid(2), {})},
|
||||
{"test/song2", 0, ebx_document(song2, {wave2})},
|
||||
{"test/wave2", 0, ebx_document(wave2, {})}},
|
||||
{{"test/wave2", {std::byte{2}}}});
|
||||
fixture.add("map", true, map_toc, {map_superbundle}, game_copy());
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, "two music mods: the merge builds the patch\n" + describe(report));
|
||||
expect(noted(report, std::string("map: ") + bundle_name + ": 6 asset(s) added by other mods, e.g. test/song1"),
|
||||
"two music mods: the map's copy receives all six assets from both mods\n" + describe(report));
|
||||
const auto files = fixture.merged_files(map_toc);
|
||||
expect(files == baseline + 6, "two music mods: the map's copy gained 6 files (3 per music mod): " +
|
||||
std::to_string(files) + " vs " + std::to_string(baseline + 6));
|
||||
}
|
||||
|
||||
// The game keeps the playlist in a second bundle too, as it keeps the music playlist in eight. A
|
||||
// mod that ships its own copy of that one (it changed something else in it) takes the music mod's
|
||||
// playlist there. The song, its wave and the wave's resource were added beside the playlist in
|
||||
// the first bundle; they have to be beside it in this one as well, or the list names a song its
|
||||
// bundle does not hold. The asset nothing names stays out, and the music mod's own bundle is as
|
||||
// that mod built it. Either mod may come first.
|
||||
void carried_into_another_bundle_with_the_list(bool music_first) {
|
||||
const std::string order = music_first ? "second bundle, music first" : "second bundle, music last";
|
||||
Fixture fixture(music_first ? "second-bundle-music-first" : "second-bundle-music-last");
|
||||
const auto add_music = [&] { fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); };
|
||||
const auto add_tweak = [&] {
|
||||
fixture.add("tweak", false, shared_toc, {},
|
||||
{{"test/playlist", 0, ebx_document(guid(1), {})}, {"test/extra", 1, ebx_document(guid(3), {})}}, {}, second_bundle);
|
||||
};
|
||||
if (music_first) { add_music(); add_tweak(); } else { add_tweak(); add_music(); }
|
||||
const auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.issue.empty() && report.built, order + ": the merge builds the patch\n" + describe(report));
|
||||
expect(noted(report, std::string("tweak: ") + second_bundle + ": 1 asset(s) take another mod's change, e.g. test/playlist from music"),
|
||||
order + ": the copy of the second bundle takes the changed playlist\n" + describe(report));
|
||||
expect(noted(report, std::string("tweak: ") + second_bundle + ": 3 asset(s) added by other mods, e.g. test/song"),
|
||||
order + ": and with it the song, its wave and the wave's resource\n" + describe(report));
|
||||
const auto files = fixture.merged_files(shared_toc, second_bundle);
|
||||
expect(files == 1 + 2 + 3, order + ": that copy lists its manifest, its two assets and those three (" + std::to_string(files) + " files)");
|
||||
const auto own = fixture.merged_files(shared_toc);
|
||||
const auto built = static_cast<int>(1 + music_assets().size() + music_resources().size());
|
||||
expect(own == built, order + ": the music mod's own bundle is as it built it (" + std::to_string(own) + " files, " +
|
||||
std::to_string(built) + " built)");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() try {
|
||||
const auto baseline = map_only();
|
||||
expect(baseline > 0, "map only: the map's copy exists (" + std::to_string(baseline) + ")");
|
||||
carried_into_the_maps_copy(true, baseline);
|
||||
carried_into_the_maps_copy(false, baseline);
|
||||
copy_in_the_adders_toc_is_left_alone();
|
||||
same_name_from_two_mods(true);
|
||||
same_name_from_two_mods(false);
|
||||
two_music_mods_both_carried_into_maps_copy(baseline);
|
||||
carried_into_another_bundle_with_the_list(true);
|
||||
carried_into_another_bundle_with_the_list(false);
|
||||
if (failures) {
|
||||
std::cerr << failures << " failure(s)\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "mod merge added assets: ok\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,10 @@ int failures = 0;
|
||||
void check(bool condition, const char* what) {
|
||||
if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; }
|
||||
}
|
||||
// A mod's archive: the package folder it is in and its number there.
|
||||
std::pair<std::string, std::uint16_t> of(const std::string& directory, std::uint16_t archive) {
|
||||
return {directory, archive};
|
||||
}
|
||||
bool lands(const ArchivePlacement& placement, std::string_view directory, std::uint16_t archive, std::uint32_t offset,
|
||||
std::uint16_t expected_archive, std::uint32_t expected_offset) {
|
||||
return unshift(placement, directory, archive, offset) && archive == expected_archive && offset == expected_offset;
|
||||
@@ -24,8 +28,8 @@ int main() {
|
||||
|
||||
// The usual launch placement: each archive at the start of an index of its own.
|
||||
ArchivePlacement own;
|
||||
own.at[{initial, 1}] = {4, 0};
|
||||
own.at[{standard, 1}] = {9, 0};
|
||||
own.at[of(initial, 1)] = {4, 0};
|
||||
own.at[of(standard, 1)] = {9, 0};
|
||||
check(lands(own, initial, 4, 1234, 1, 1234), "an archive with an index of its own keeps its offsets");
|
||||
check(lands(own, standard, 9, 0, 1, 0), "the other package folder has its own blocks");
|
||||
std::uint16_t archive = 9;
|
||||
@@ -35,9 +39,9 @@ int main() {
|
||||
|
||||
// A mod added while the game runs: archives 1, 2 and 3 appended to the patch's archive 1.
|
||||
ArchivePlacement appended;
|
||||
appended.at[{initial, 1}] = {1, 5000};
|
||||
appended.at[{initial, 2}] = {1, 9096};
|
||||
appended.at[{initial, 3}] = {1, 20000};
|
||||
appended.at[of(initial, 1)] = {1, 5000};
|
||||
appended.at[of(initial, 2)] = {1, 9096};
|
||||
appended.at[of(initial, 3)] = {1, 20000};
|
||||
check(lands(appended, initial, 1, 5000, 1, 0), "the first block starts at its own byte 0");
|
||||
check(lands(appended, initial, 1, 9095, 1, 4095), "the last byte of the first block is still the first archive");
|
||||
check(lands(appended, initial, 1, 9096, 2, 0), "an offset in the second block is the second archive");
|
||||
@@ -49,8 +53,8 @@ int main() {
|
||||
|
||||
// An empty archive's block starts where the next one does; the data is in the next.
|
||||
ArchivePlacement empty_first;
|
||||
empty_first.at[{initial, 1}] = {1, 700};
|
||||
empty_first.at[{initial, 2}] = {1, 700};
|
||||
empty_first.at[of(initial, 1)] = {1, 700};
|
||||
empty_first.at[of(initial, 2)] = {1, 700};
|
||||
check(lands(empty_first, initial, 1, 700, 2, 0) && lands(empty_first, initial, 1, 900, 2, 200),
|
||||
"an empty archive's block yields to the one that follows it");
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Checks how a mod's reskate-music.json is read: the playlist ids and names, which song entries are
|
||||
// kept, the limits, and that a document of the wrong shape is refused rather than half-read.
|
||||
#include "Engine/Vfs/mod_music.h"
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
void check(bool condition, const std::string& message) {
|
||||
if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; }
|
||||
}
|
||||
|
||||
using dingosdk::mods::parse_music_playlists;
|
||||
|
||||
bool refused(const std::string& json) {
|
||||
try { parse_music_playlists("mod", json); } catch (const std::exception&) { return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string playlist_of(const std::string& songs) {
|
||||
return R"({"schema":1,"playlists":[{"name":"Mix","songs":)" + songs + "}]}";
|
||||
}
|
||||
}
|
||||
|
||||
int main() try {
|
||||
{
|
||||
const auto lists = parse_music_playlists("KevinMacLeod", R"({
|
||||
"schema": 1,
|
||||
"playlists": [
|
||||
{"name": "Kevin MacLeod", "songs": ["Kevin MacLeod - Cipher", "Kevin MacLeod - Sneaky Snitch"]},
|
||||
{"name": "Second", "songs": []}
|
||||
]})");
|
||||
check(lists.size() == 2, "both playlists are read");
|
||||
check(lists.at(0).id == "mod:KevinMacLeod:Kevin MacLeod", "id is mod:<mod>:<name>");
|
||||
check(lists.at(0).name == "Kevin MacLeod", "name is the shown name");
|
||||
check(lists.at(0).songs == std::vector<std::string>{"Kevin MacLeod - Cipher", "Kevin MacLeod - Sneaky Snitch"},
|
||||
"songs keep their order and spelling");
|
||||
check(lists.at(1).songs.empty(), "an empty playlist is kept (the catalog drops it when nothing matches)");
|
||||
}
|
||||
{
|
||||
const auto lists = parse_music_playlists("mod", playlist_of(
|
||||
R"(["A - One", "", 7, null, "B - Tw\to", ")" + std::string(256, 'x') + R"(", "C - Three"])"));
|
||||
check(lists.at(0).songs == std::vector<std::string>{"A - One", "C - Three"},
|
||||
"empty, non-string, control-character and over-long entries are dropped, the rest kept");
|
||||
}
|
||||
{
|
||||
std::string songs = "[";
|
||||
for (int i = 0; i < 1100; ++i) songs += (i ? "," : "") + std::string("\"A - ") + std::to_string(i) + "\"";
|
||||
const auto lists = parse_music_playlists("mod", playlist_of(songs + "]"));
|
||||
check(lists.at(0).songs.size() == 1024, "at most 1024 songs per playlist");
|
||||
|
||||
std::string many = R"({"schema":1,"playlists":[)";
|
||||
for (int i = 0; i < 70; ++i) many += (i ? "," : "") + std::string(R"({"name":"P)") + std::to_string(i) + R"(","songs":[]})";
|
||||
check(parse_music_playlists("mod", many + "]}").size() == 64, "at most 64 playlists");
|
||||
}
|
||||
check(parse_music_playlists("mod", R"({"schema":1,"playlists":[]})").empty(), "no playlists is fine");
|
||||
{
|
||||
const auto lists = parse_music_playlists("mod", R"({"schema":1,"playlists":[
|
||||
{"name":"Mix","artwork":"artwork/playlist.png","songs":["A - One","B - Two"]}],
|
||||
"song_artwork":{"A - One":"artwork/track.png","B - Two":"../outside.png","Unlisted":"artwork/other.png"}})");
|
||||
check(lists[0].artwork == "artwork/playlist.png", "playlist cover is read");
|
||||
check(lists[0].song_artwork.size() == 1 && lists[0].song_artwork.at("A - One") == "artwork/track.png",
|
||||
"song covers are limited to members and safe paths");
|
||||
for (const auto* path : {"../cover.png", "/cover.png", "C:/cover.png", "a\\cover.png", "a/../cover.png",
|
||||
"a//cover.png", "a/./cover.png", "cover.png:stream", "a%2fb.png", "cover.jpg"})
|
||||
check(!dingosdk::mods::music_artwork_path(path), std::string("unsafe artwork path: ") + path);
|
||||
const auto bad = parse_music_playlists("mod", R"({"schema":1,"playlists":[
|
||||
{"name":"Mix","artwork":42,"songs":["A - One"]}],"song_artwork":[]})");
|
||||
check(bad[0].artwork.empty() && bad[0].songs.size() == 1, "bad optional artwork does not discard music");
|
||||
}
|
||||
check(refused(R"({"schema":2,"playlists":[]})"), "another schema version is refused");
|
||||
check(refused(R"({"playlists":[]})"), "a missing schema is refused");
|
||||
check(refused(R"({"schema":1})"), "missing playlists is refused");
|
||||
check(refused(R"({"schema":1,"playlists":{}})"), "playlists that is not an array is refused");
|
||||
check(refused(R"({"schema":1,"playlists":[{"songs":[]}]})"), "a playlist without a name is refused");
|
||||
check(refused(R"({"schema":1,"playlists":[{"name":"","songs":[]}]})"), "an empty name is refused");
|
||||
check(refused(R"({"schema":1,"playlists":[{"name":"X"}]})"), "a playlist without songs is refused");
|
||||
check(refused(R"({"schema":1,"playlists":[{"name":"X","songs":"A - B"}]})"), "songs that is not an array is refused");
|
||||
check(refused("not json"), "text that is not JSON is refused");
|
||||
|
||||
if (failures == 0) std::cout << "mod music tests passed\n";
|
||||
return failures == 0 ? 0 : 1;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAIL: uncaught: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Mods that add copies of items the game's store sells (Engine/Vfs/mod_store_copies.h).
|
||||
//
|
||||
// ReSkate never hands out a store item, and tells one by its key. The mods that got round that
|
||||
// (2026-10-05) all did it the same way: each store item again under a key of its own, which is
|
||||
// in no catalogue and so was handed out like any mod's item. The check tells a copy by what it
|
||||
// is made of, and the merge leaves a mod that adds one out whole, saying only that it could
|
||||
// not be merged. What it must not do is take a mod's own item for one: mods fit textures of
|
||||
// their own on the game's meshes, a store item's included, and that is theirs.
|
||||
//
|
||||
// Arguments: [<Skate folder> [<folder of mods>...]]. With the game and its content cache (which
|
||||
// says what the store sells) the check is run on mods made here from the game's own items; the
|
||||
// mods in each further folder are then checked and listed, to try it on real ones.
|
||||
#include "Engine/Resource/binary_bundle.h"
|
||||
#include "Engine/Resource/cas_codec.h"
|
||||
#include "Engine/Resource/ebx_carry.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
#include "Engine/Resource/ebx_writer.h"
|
||||
#include "Engine/Resource/toc.h"
|
||||
#include "Engine/Vfs/content_cache.h"
|
||||
#include "Engine/Vfs/content_catalogs.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Engine/Vfs/mod_merge.h"
|
||||
#include "Engine/Vfs/mod_merge_internal.h"
|
||||
#include "Engine/Vfs/mod_store_copies.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
namespace fb = dingosdk::frostbite;
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::mods;
|
||||
using namespace dingosdk::mods::detail;
|
||||
|
||||
namespace {
|
||||
using Bytes = std::vector<std::byte>;
|
||||
constexpr char shared_bundle[] = "win32/characters/customization/configs/cas_main_sharedbundle";
|
||||
|
||||
int failures = 0;
|
||||
void check(bool ok, const std::string& what) {
|
||||
if (ok) return;
|
||||
std::cerr << "FAIL: " << what << '\n';
|
||||
++failures;
|
||||
}
|
||||
|
||||
ItemContent made_of(std::string key, std::vector<std::string> looks, std::vector<std::string> shapes, std::string data) {
|
||||
return {std::move(key), std::move(looks), std::move(shapes), std::move(data)};
|
||||
}
|
||||
|
||||
// What counts as a copy, on items written out here.
|
||||
void rules() {
|
||||
StoreItems game;
|
||||
game.add(made_of("Own_Store_Hoodie", {"asset 3 store_hoodie_ap"}, {"asset 1 hoodie_dmpreset"}, "a"), true);
|
||||
game.add(made_of("Own_Free_Hoodie", {"asset 3 free_hoodie_ap"}, {"asset 1 hoodie_dmpreset"}, "b"), false);
|
||||
game.add(made_of("Own_Store_Coat", {"asset 3 store_coat_ap"}, {"asset 1 coat_dmpreset"}, "c"), true);
|
||||
game.add(made_of("Own_Store_Costume", {}, {"asset 1 costume_dmpreset"}, "d"), true);
|
||||
game.add(made_of("Own_Store_Title", {}, {}, ""), true);
|
||||
game.add(made_of("Own_Free_Title", {}, {}, ""), false);
|
||||
game.add(made_of("Own_Store_Voice", {}, {}, "e"), true);
|
||||
game.add(made_of("Own_Store_Frame", {}, {}, "f"), true);
|
||||
game.add(made_of("Own_Free_Frame", {}, {}, "f"), false);
|
||||
|
||||
const auto copies = [&](std::vector<std::string> looks, std::vector<std::string> shapes, std::string data = "mod") {
|
||||
return game.original(made_of("Own_Mod_Item", std::move(looks), std::move(shapes), std::move(data)));
|
||||
};
|
||||
check(copies({"asset 3 store_hoodie_ap"}, {"asset 1 hoodie_dmpreset"}) == "Own_Store_Hoodie", "a store item's look is that store item");
|
||||
check(copies({"asset 3 junk_ap", "asset 3 store_hoodie_ap"}, {}) == "Own_Store_Hoodie", "and stays it with more beside it");
|
||||
check(copies({"asset 3 free_hoodie_ap"}, {"asset 1 hoodie_dmpreset"}).empty(), "a free item's look is free");
|
||||
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 1 hoodie_dmpreset"}).empty(), "a shape free items use is free");
|
||||
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");
|
||||
check(copies({}, {}, "").empty(), "data with nothing in it says nothing");
|
||||
check(copies({}, {}).empty(), "an item of the mod's own is no copy");
|
||||
}
|
||||
|
||||
fb::Guid guid(std::uint8_t seed) {
|
||||
fb::Guid value;
|
||||
for (std::size_t index = 0; index < value.bytes.size(); ++index)
|
||||
value.bytes[index] = static_cast<std::byte>(static_cast<std::uint8_t>(seed + index));
|
||||
return value;
|
||||
}
|
||||
|
||||
// The game's shared customization bundle, which holds every item, and two of them to make mods
|
||||
// from: one the store sells and one it does not, both clothing (a look fitted on a shape).
|
||||
struct Game {
|
||||
fs::path root, base;
|
||||
fb::TocBundle bundle;
|
||||
Listing listing;
|
||||
fb::ebx::Document sold, free;
|
||||
std::string soldKey, freeKey;
|
||||
// A player-card background the store sells: its picture is another asset, and it is kept
|
||||
// outside items/.
|
||||
fb::ebx::Document card;
|
||||
std::string cardKey;
|
||||
};
|
||||
Game read_game(const fs::path& root, const CasStore& store, const StoreItem& sold) {
|
||||
Game game{root, root / L"Data"};
|
||||
bool found{};
|
||||
for (const auto& bundle : fb::read_toc(read_file(game.base / L"Win32" / L"items.toc")).bundles) {
|
||||
if (lower(bundle.name) != shared_bundle) continue;
|
||||
if (auto listing = list_bundle(store, game.base, game.base, bundle, root)) {
|
||||
game.bundle = bundle;
|
||||
game.listing = std::move(*listing);
|
||||
found = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!found) throw std::runtime_error(std::string("no readable ") + shared_bundle);
|
||||
const auto& assets = game.listing.manifest.ebx;
|
||||
for (std::size_t index = 0; index < assets.size() && (game.soldKey.empty() || game.freeKey.empty() || game.cardKey.empty()); ++index) {
|
||||
const auto name = lower(assets[index].name);
|
||||
if (!name.starts_with("items/cust_") && !name.starts_with("thumbnail/pcard/bg/ownable/")) continue;
|
||||
auto document = fb::ebx::read_document(read_asset(store, game.base, game.base, game.listing, index, root));
|
||||
const auto item = item_content(document);
|
||||
if (!item || item->looks.size() != 1) continue;
|
||||
const auto store_item = sold(lower(item->key));
|
||||
if (store_item && game.cardKey.empty() && item->shapes.empty() && item->looks.front().starts_with("picture ")) {
|
||||
game.cardKey = item->key;
|
||||
game.card = std::move(document);
|
||||
continue;
|
||||
}
|
||||
if (item->shapes.empty() || !item->looks.front().starts_with("asset 3 ")) continue;
|
||||
auto& key = store_item ? game.soldKey : game.freeKey;
|
||||
if (!key.empty()) continue;
|
||||
key = item->key;
|
||||
(store_item ? game.sold : game.free) = std::move(document);
|
||||
}
|
||||
if (game.soldKey.empty() || game.freeKey.empty() || game.cardKey.empty())
|
||||
throw std::runtime_error("the game has no store item, free item or store card background to copy");
|
||||
return game;
|
||||
}
|
||||
|
||||
// The item's data, to change in a copy of it.
|
||||
fb::ebx::Object& item_data(fb::ebx::Document& item) {
|
||||
for (auto& field : item.instances.front().object->fields) {
|
||||
if (field.name != "ItemData") continue;
|
||||
const auto& pointer = std::get<fb::ebx::PointerReference>(field.value.data);
|
||||
return *item.instances.at(static_cast<std::size_t>(pointer.index)).object;
|
||||
}
|
||||
throw std::runtime_error("an item has no ItemData");
|
||||
}
|
||||
fb::ebx::Value& field_of(fb::ebx::Object& object, std::string_view name) {
|
||||
for (auto& field : object.fields)
|
||||
if (field.name == name) return field.value;
|
||||
throw std::runtime_error("an item has no " + std::string(name));
|
||||
}
|
||||
|
||||
// One of the game's items under a name and identity of its own: what a cosmetic mod adds.
|
||||
struct Item {
|
||||
std::string name;
|
||||
Bytes payload;
|
||||
};
|
||||
Item item_from(const fb::ebx::Document& donor, const std::string& name, std::uint8_t seed,
|
||||
const std::function<void(fb::ebx::Document&)>& change = {}) {
|
||||
auto item = fb::ebx::detail::clone_document(donor);
|
||||
auto& root = item.instances.front();
|
||||
item.fileGuid = guid(seed);
|
||||
root.instanceGuid = guid(static_cast<std::uint8_t>(seed + 0x40));
|
||||
const auto key = name.substr(name.rfind('/') + 1);
|
||||
std::uint32_t hash = 5381; // the game's own hash of an item's key
|
||||
for (const unsigned char character : key) hash = hash * 33U ^ character;
|
||||
for (auto& field : root.object->fields) {
|
||||
if (field.name == "Name") field.value.data = name;
|
||||
else if (field.name == "Key") field.value.data = key;
|
||||
else if (field.name == "HashedAssetKey") {
|
||||
if (std::holds_alternative<std::int64_t>(field.value.data)) field.value.data = static_cast<std::int64_t>(hash);
|
||||
else field.value.data = static_cast<std::uint64_t>(hash);
|
||||
}
|
||||
}
|
||||
if (change) change(item);
|
||||
return {name, fb::ebx::write_document(item)};
|
||||
}
|
||||
|
||||
// A cosmetic mod's folder: its copy of the shared bundle in items.toc (the game's assets and its
|
||||
// own items) and one archive holding the manifest and those items.
|
||||
Mod write_mod(const fs::path& mods, const std::string& name, const CasStore& store, const Game& game,
|
||||
std::initializer_list<Item> items) {
|
||||
const auto directory = mods / name;
|
||||
auto manifest = game.listing.manifest;
|
||||
const auto& shipped = game.listing.files;
|
||||
std::vector<fb::BundleFileInfo> files(shipped.begin() + static_cast<std::ptrdiff_t>(game.listing.first), shipped.end());
|
||||
const auto installChunk = shipped.front().location.installChunk;
|
||||
Bytes archive;
|
||||
const auto stored = [&](std::span<const std::byte> bytes) {
|
||||
const fb::BundleFileInfo file{{true, installChunk, 1}, static_cast<std::uint32_t>(archive.size()),
|
||||
static_cast<std::uint32_t>(bytes.size())};
|
||||
archive.insert(archive.end(), bytes.begin(), bytes.end());
|
||||
return file;
|
||||
};
|
||||
for (const auto& item : items) {
|
||||
fb::BundleAsset asset;
|
||||
asset.kind = fb::AssetKind::ebx;
|
||||
asset.name = item.name;
|
||||
asset.sha1 = sha1_of(item.payload);
|
||||
asset.originalSize = item.payload.size();
|
||||
files.insert(files.begin() + static_cast<std::ptrdiff_t>(manifest.ebx.size()),
|
||||
stored(fb::encode_cas(item.payload, {.compression = fb::CasCompression::raw})));
|
||||
manifest.ebx.push_back(std::move(asset));
|
||||
}
|
||||
files.insert(files.begin(), stored(fb::write_binary_bundle(manifest)));
|
||||
const std::vector<fb::TocBundle> bundles{{game.bundle.name, fb::write_bundle_region(files), 1}};
|
||||
fs::create_directories(directory / L"Win32" / fs::path(store.directory(installChunk)));
|
||||
write_file(directory / L"Win32" / L"items.toc", fb::write_patch_toc(bundles));
|
||||
write_file(directory / L"Win32" / fs::path(store.directory(installChunk)) / L"cas_01.cas", archive);
|
||||
fs::copy_file(game.base / L"layout.toc", directory / L"layout.toc", fs::copy_options::overwrite_existing);
|
||||
Mod mod;
|
||||
mod.name = name;
|
||||
mod.directory = directory;
|
||||
mod.provides_layout = true;
|
||||
return mod;
|
||||
}
|
||||
|
||||
std::string describe(const StoreCopies& copies, const std::vector<std::string>& notes) {
|
||||
std::string text;
|
||||
for (const auto& [copy, original] : copies.items) text += "\n " + copy + " copies " + original;
|
||||
for (const auto& note : notes) text += "\n note: " + note;
|
||||
return text;
|
||||
}
|
||||
|
||||
// Mods made from the game's own items, checked against the game.
|
||||
void made_mods(const Game& game, const CasStore& store, const StoreItem& sold) {
|
||||
const auto root = fs::temp_directory_path() / ("reskate-store-copies-" + std::to_string(GetCurrentProcessId()));
|
||||
std::error_code error;
|
||||
fs::remove_all(root, error);
|
||||
Catalog catalog;
|
||||
catalog.data_root = game.root;
|
||||
catalog.root = root / "Mods";
|
||||
catalog.present = true;
|
||||
|
||||
// The store item under another key, as the mods did it; and again with its colour changed, so
|
||||
// it is no longer the same data but still names the store item's appearance.
|
||||
const auto plain = item_from(game.sold, "items/reskate_test/own_test_copy", 0x10);
|
||||
const auto tinted = item_from(game.sold, "items/reskate_test/own_test_tinted", 0x20, [](fb::ebx::Document& item) {
|
||||
auto& colour = std::get<std::shared_ptr<fb::ebx::Object>>(field_of(item_data(item), "BaseColor").data);
|
||||
field_of(*colour, "x").data = 0.75;
|
||||
});
|
||||
// A mod's own item on the store item's shape: every appearance it names is the mod's.
|
||||
const auto own = item_from(game.sold, "items/reskate_test/own_test_own_look", 0x30, [](fb::ebx::Document& item) {
|
||||
for (auto& entry : std::get<fb::ebx::Value::Array>(field_of(item_data(item), "AssetPaths").data)) {
|
||||
auto& path = *std::get<std::shared_ptr<fb::ebx::Object>>(entry.data);
|
||||
const auto& kind = field_of(path, "AssetTypeId").data;
|
||||
const auto shape = std::holds_alternative<std::int64_t>(kind) ? std::get<std::int64_t>(kind) < 3
|
||||
: std::get<std::uint64_t>(kind) < 3;
|
||||
if (!shape) field_of(path, "AssetName").data = std::string("ReSkate_Test_Own_Look_AP");
|
||||
}
|
||||
});
|
||||
// An item the store does not sell, under another key.
|
||||
const auto freebie = item_from(game.free, "items/reskate_test/own_test_free_copy", 0x50);
|
||||
// A store card background under another key: no preset or texture name, only its picture.
|
||||
const auto card = item_from(game.card, "items/reskate_test/own_test_card", 0x60);
|
||||
|
||||
catalog.mods.push_back(write_mod(catalog.root, "honest", store, game, {own, freebie}));
|
||||
catalog.mods.push_back(write_mod(catalog.root, "copies", store, game, {plain, tinted, card}));
|
||||
catalog.mods.push_back(write_mod(catalog.root, "nothing", store, game, {}));
|
||||
std::vector<std::string> notes;
|
||||
const auto found = check_store_copies(catalog, sold, ¬es);
|
||||
const auto said = describe(found, notes);
|
||||
const auto store_key = lower(game.soldKey);
|
||||
check(found.game_items > 3000, "the game's items were read (" + std::to_string(found.game_items) + ")");
|
||||
check(found.items.size() == 3, "three of the five items are copies of a store item" + said);
|
||||
check(found.items.contains("own_test_copy") && found.items.at("own_test_copy") == store_key,
|
||||
"the store item under another key is a copy of it" + said);
|
||||
check(found.items.contains("own_test_tinted") && found.items.at("own_test_tinted") == store_key,
|
||||
"and still is with its colour changed" + said);
|
||||
check(found.items.contains("own_test_card") && found.items.at("own_test_card") == lower(game.cardKey),
|
||||
"a store card background under another key is a copy of it" + said);
|
||||
check(!found.items.contains("own_test_own_look"), "a look of the mod's own on the store item's shape is not" + said);
|
||||
check(!found.items.contains("own_test_free_copy"), "a copy of an item the store does not sell is not" + said);
|
||||
check(found.mods.size() == 1 && found.mods.front().mod == "copies" && found.mods.front().count == 3,
|
||||
"only the mod that adds them is named" + said);
|
||||
check(notes.empty(), "nothing went unread" + said);
|
||||
// With no store at all (no content cache) nothing is anyone's copy.
|
||||
check(check_store_copies(catalog, [](const std::string&) { return false; }).items.empty(), "nothing is a copy when the store sells nothing");
|
||||
|
||||
// The merge builds nothing while such a mod is among them, and says whose problem it is, so
|
||||
// the caller leaves that mod out; without it the others merge as they always did. The
|
||||
// problem is one line that tells the mod's author nothing: no item, no count, no reason.
|
||||
const auto told = [](const MergeReport& report) {
|
||||
std::string text = "\n issue: '" + report.issue + "'";
|
||||
for (const auto& [mod, list] : report.problems)
|
||||
for (const auto& problem : list) text += "\n " + mod + ": " + problem;
|
||||
return text;
|
||||
};
|
||||
const auto refused = merge_mods(catalog);
|
||||
check(refused.issue.empty() && !refused.built, "nothing is built with a mod that copies store items among them" + told(refused));
|
||||
check(refused.problems.size() == 1 && refused.problems.contains("copies") &&
|
||||
refused.problems.at("copies") == std::vector<std::string>{std::string(store_copies_problem)},
|
||||
"and the problem is that mod's alone, and only the one line" + told(refused));
|
||||
const auto lower_case = [](std::string text) { return lower(text); };
|
||||
for (const auto* word : {"store", "copy", "copies", "item", "own_"})
|
||||
check(lower_case(std::string(store_copies_problem)).find(word) == std::string::npos &&
|
||||
lower_case(std::string(store_copies_check)).find(word) == std::string::npos,
|
||||
std::string("what the mod is told does not say '") + word + "'");
|
||||
check(!fs::exists(catalog.root / generated_folder / L"layout.toc", error), "no patch was written for them");
|
||||
std::erase_if(catalog.mods, [](const Mod& mod) { return mod.name == "copies"; });
|
||||
const auto merged = merge_mods(catalog);
|
||||
check(merged.issue.empty() && merged.built && merged.problems.empty(), "the other mods merge without it" + told(merged));
|
||||
fs::remove_all(root, error);
|
||||
}
|
||||
|
||||
// Real mods, listed rather than judged.
|
||||
void installed_mods(const fs::path& game, const fs::path& folder, const StoreItem& sold) {
|
||||
Catalog catalog;
|
||||
catalog.data_root = game;
|
||||
catalog.root = fs::temp_directory_path() / L"reskate-store-copies-unused";
|
||||
catalog.present = true;
|
||||
std::error_code error;
|
||||
for (fs::directory_iterator it(folder, error), end; it != end && !error; it.increment(error)) {
|
||||
if (!it->is_directory(error) || it->path().filename().wstring().starts_with(L".") ||
|
||||
!fs::exists(it->path() / L"layout.toc", error)) continue;
|
||||
Mod mod;
|
||||
mod.name = it->path().filename().string();
|
||||
mod.directory = it->path();
|
||||
mod.provides_layout = true;
|
||||
catalog.mods.push_back(std::move(mod));
|
||||
}
|
||||
std::vector<std::string> notes;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const auto found = check_store_copies(catalog, sold, ¬es);
|
||||
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
|
||||
std::cout << folder.string() << ": " << catalog.mods.size() << " mod(s) checked in " << ms << " ms against "
|
||||
<< found.game_items << " of the game's items, " << found.items.size() << " copy(ies) of store items in "
|
||||
<< found.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';
|
||||
for (const auto& note : notes) std::cout << " note: " << note << '\n';
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) try {
|
||||
rules();
|
||||
{
|
||||
// No mods: nothing to read, the game's own files included.
|
||||
std::vector<std::string> notes;
|
||||
check(check_store_copies(Catalog{}, [](const std::string&) { return true; }, ¬es).items.empty() && notes.empty(),
|
||||
"no mods, no copies and nothing to say");
|
||||
}
|
||||
std::error_code error;
|
||||
if (argc < 2 || !fs::exists(fs::path(argv[1]) / L"Data" / L"Win32" / L"items.toc", error)) {
|
||||
std::cout << "No game given; the check was not run on mods.\n";
|
||||
} else if (!content_cache::installed()) {
|
||||
std::cout << "No content cache, so nothing says what the store sells; the check was not run on mods.\n";
|
||||
} else {
|
||||
const auto catalogs = content_cache::read_catalogs(content_cache::directory());
|
||||
const StoreItem sold = [&](const std::string& key) { return catalogs.reserved(key); };
|
||||
const fs::path root = argv[1];
|
||||
const auto layout = vfs::read_layout(root / L"Data" / L"layout.toc");
|
||||
const CasStore store(root / L"Data", fs::temp_directory_path() / L"reskate-store-copies-unused", layout.root);
|
||||
const auto game = read_game(root, store, sold);
|
||||
made_mods(game, store, sold);
|
||||
std::cout << "store copies: made from " << game.soldKey << " and " << game.cardKey << " (sold) and "
|
||||
<< game.freeKey << " (free)\n";
|
||||
for (int index = 2; index < argc; ++index) installed_mods(root, argv[index], sold);
|
||||
}
|
||||
if (failures) {
|
||||
std::cerr << failures << " failure(s)\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "store copies: ok\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
@@ -54,6 +54,40 @@ void add_item(Catalogs& out, const Fields& record) {
|
||||
out.items[key] = std::move(entry);
|
||||
}
|
||||
|
||||
// Music playlists: field 1 names the playlist, repeated field 2 lists tracks
|
||||
// ("Artist - Title"), and field 10 is a message carrying the display name and
|
||||
// artwork. Owned-item records put a varint in field 10, so requiring the byte
|
||||
// wire separates the two. First record for an id wins.
|
||||
void add_music_playlist(Catalogs& out, const std::string& id, const Fields& record) {
|
||||
const auto content = message(record, 10);
|
||||
if (!content) return;
|
||||
const auto name = text(*content, 10);
|
||||
if (!name) return;
|
||||
std::vector<std::string> tracks;
|
||||
for (const auto& field : record) {
|
||||
if (field.number != 2 || field.wire != Wire::bytes) continue;
|
||||
const auto value = text(&field);
|
||||
if (!value || value->find(" - ") == std::string::npos) continue;
|
||||
tracks.push_back(*value);
|
||||
}
|
||||
if (tracks.size() < 2) return;
|
||||
Catalogs::MusicPlaylistEntry entry;
|
||||
entry.name = *name;
|
||||
entry.artwork = text(*content, 11).value_or("");
|
||||
entry.tracks = std::move(tracks);
|
||||
out.music_playlists.try_emplace(id, std::move(entry));
|
||||
}
|
||||
|
||||
// Songs: field 1 is "Artist - Title" and field 10 a message with the artist (10), title (11) and
|
||||
// cdn:/ cover art (12). First record for an id wins.
|
||||
void add_music_song(Catalogs& out, const std::string& id, const Fields& record) {
|
||||
if (id.find(" - ") == std::string::npos) return;
|
||||
const auto content = message(record, 10);
|
||||
if (!content || !text(*content, 10) || !text(*content, 11)) return;
|
||||
if (const auto artwork = text(*content, 12); artwork && artwork->starts_with("cdn:/"))
|
||||
out.music_song_artwork.try_emplace(id, *artwork);
|
||||
}
|
||||
|
||||
// Grant lists: repeated `number` entries, each {1: kind, 2: id}.
|
||||
struct Grant { std::string kind, id; };
|
||||
std::vector<Grant> grants(const Fields& fields, std::uint32_t number) {
|
||||
@@ -221,6 +255,8 @@ Catalogs read_catalogs(const std::filesystem::path& folder) {
|
||||
if (!id) continue;
|
||||
if (text(*fields, 3) == "entitlement") entitlements.insert(*id);
|
||||
add_item(out, *fields);
|
||||
add_music_playlist(out, *id, *fields);
|
||||
add_music_song(out, *id, *fields);
|
||||
add_challenge(out, *id, *fields);
|
||||
add_object_category(out, *id, *fields);
|
||||
add_travel(out, *id, *fields);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -40,6 +41,17 @@ struct Catalogs {
|
||||
std::vector<TravelLocation> travel_locations;
|
||||
// Access point id (accesspoint_*) -> the location ids it offers.
|
||||
std::vector<std::pair<std::string, std::vector<std::string>>> travel_access_points;
|
||||
// Music playlist id -> {display name, artwork id, track ids} from the music
|
||||
// chunk. A music record carries a field-10 message (10.10 name, 10.11
|
||||
// artwork) and repeated field-2 tracks; owned items use a varint field 10.
|
||||
struct MusicPlaylistEntry {
|
||||
std::string name, artwork;
|
||||
std::vector<std::string> tracks;
|
||||
};
|
||||
std::map<std::string, MusicPlaylistEntry, std::less<>> music_playlists;
|
||||
// Song ("Artist - Title") -> its cdn:/ cover art: a song record carries a field-10 message
|
||||
// (10.10 artist, 10.11 title, 10.12 artwork).
|
||||
std::map<std::string, std::string, std::less<>> music_song_artwork;
|
||||
bool reserved(const std::string& key) const;
|
||||
};
|
||||
// Read once, on first use. Empty (available == false) when no pack is installed.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#include <Windows.h>
|
||||
#include <winhttp.h>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::https {
|
||||
@@ -72,4 +75,24 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
|
||||
result.ok = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
|
||||
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result) {
|
||||
static std::atomic<unsigned> serial{};
|
||||
wchar_t folder[MAX_PATH + 1]{};
|
||||
const auto length = GetTempPathW(MAX_PATH, folder);
|
||||
const auto path = std::filesystem::path(length ? std::wstring(folder, length) : L".") /
|
||||
(L"reskate-get-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(++serial) + L".tmp");
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error); // get needs a fresh destination
|
||||
const auto download = get(url, path, max_bytes, timeout_seconds, agent);
|
||||
if (result) *result = download;
|
||||
std::optional<std::string> text;
|
||||
if (download.ok) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
text.emplace(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
std::filesystem::remove(path, error);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk::https {
|
||||
@@ -13,4 +15,8 @@ struct Download {
|
||||
// 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);
|
||||
// The body of the same GET, for an answer small enough to hold: nothing when it
|
||||
// fails, and `result`, when given, says how.
|
||||
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
|
||||
std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr);
|
||||
}
|
||||
@@ -139,15 +139,9 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
|
||||
if (!entry.enabled) {
|
||||
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.size() < maximum_mods)
|
||||
result.inactive.push_back(std::move(entry.mod));
|
||||
if (entry.mod.provides_layout) result.inactive.push_back(std::move(entry.mod));
|
||||
continue;
|
||||
}
|
||||
if (result.mods.size() >= maximum_mods) {
|
||||
result.notes.push_back("More than " + std::to_string(maximum_mods) +
|
||||
" mods are present; " + entry.mod.name + " and any later mod were skipped");
|
||||
break;
|
||||
}
|
||||
if (!entry.mod.provides_layout && !entry.mod.provides_levels && entry.mod.park_maps.empty())
|
||||
result.notes.push_back("Mod " + entry.mod.name + " has no layout.toc or reskate-levels.json");
|
||||
result.mods.push_back(std::move(entry.mod));
|
||||
|
||||
@@ -23,7 +23,9 @@ constexpr char levels_name[] = "reskate-levels.json";
|
||||
// Written by the tool that built the mod (Studio, since 2026-09-24): who made
|
||||
// it and when. Purely informational, so it is read loosely and never rejected.
|
||||
constexpr char build_name[] = "reskate-build.json";
|
||||
constexpr std::size_t maximum_order_bytes = 256 * 1024;
|
||||
// mods.json and the list of mods left out: as much as the JSON reader itself takes, so the
|
||||
// number of mods is not held down by the size of the file that lists them.
|
||||
constexpr std::size_t maximum_order_bytes = 4 * 1024 * 1024;
|
||||
constexpr std::size_t maximum_build_bytes = 16 * 1024;
|
||||
constexpr std::size_t maximum_info_bytes = 32 * 1024;
|
||||
constexpr std::size_t maximum_levels_bytes = 64 * 1024;
|
||||
@@ -199,8 +201,6 @@ std::vector<std::pair<std::string, bool>> read_order(const fs::path& path) {
|
||||
throw std::runtime_error("mods.json schema must be integer 1");
|
||||
const auto& rows = root.at("mods");
|
||||
if (!rows.is_array()) throw std::runtime_error("mods.json mods must be an array");
|
||||
if (rows.size() > maximum_mods)
|
||||
throw std::runtime_error("mods.json lists more than " + std::to_string(maximum_mods) + " mods");
|
||||
|
||||
std::vector<std::pair<std::string, bool>> order;
|
||||
std::set<std::string, std::less<>> seen;
|
||||
@@ -398,8 +398,6 @@ ModList scan_mods(const std::filesystem::path& data_root) noexcept {
|
||||
}
|
||||
|
||||
void save_mod_order(const std::filesystem::path& mods_root, const std::vector<ModEntry>& entries) {
|
||||
if (entries.size() > maximum_mods)
|
||||
throw std::runtime_error("At most " + std::to_string(maximum_mods) + " mods can be listed");
|
||||
auto rows = Json::array();
|
||||
for (const auto& entry : entries) {
|
||||
if (!valid_mod_name(entry.mod.name))
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
// happens here, so the launcher's mod manager and the runtime share it.
|
||||
namespace dingosdk::mods {
|
||||
|
||||
inline constexpr std::size_t maximum_mods = 64;
|
||||
// There is no limit on how many mods are listed or loaded: nothing in the merge needs one
|
||||
// (archives are numbered with 16 bits). There was one of 64 once, which players reached: past
|
||||
// it the launcher could not save the list at all, and the game skipped the later mods.
|
||||
inline constexpr std::size_t maximum_mod_name = 64;
|
||||
// Generated state the SDK owns. Folders beginning with a dot are never mods.
|
||||
inline constexpr char generated_folder[] = ".reskate";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "mod_merge.h"
|
||||
|
||||
#include "content_cache.h"
|
||||
#include "content_catalogs.h"
|
||||
#include "mod_merge_internal.h"
|
||||
#include "mod_store_copies.h"
|
||||
#include "native_db.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -29,6 +32,21 @@ constexpr std::string_view root_level = "win32/levels/game/dingolevel_root/dingo
|
||||
// installed while the game runs has a mounted copy to replace in memory
|
||||
// (Extension/Assets/live_mods.cpp) and one disabled can be swapped back out.
|
||||
constexpr std::array<std::string_view, 2> launch_superbundles{"Win32/globals.toc", "Win32/items.toc"};
|
||||
|
||||
// Each enabled mod that adds copies of items the game's store sells
|
||||
// (mod_store_copies.h) gets a problem; true when any does. The problem says no
|
||||
// more than that the mod could not be merged: what was found is not for the
|
||||
// mod's author to read. The catalogue is only read once a mod turns out to add
|
||||
// an item at all.
|
||||
bool store_copy_problems(const Catalog& catalog, MergeReport& report) {
|
||||
std::optional<content_cache::Catalogs> store;
|
||||
const auto found = check_store_copies(catalog, [&store](const std::string& key) {
|
||||
if (!store) store = content_cache::read_catalogs(content_cache::directory());
|
||||
return store->reserved(key);
|
||||
}, &report.notes);
|
||||
for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem);
|
||||
return !found.mods.empty();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, const MergeOptions& options) noexcept {
|
||||
@@ -45,16 +63,23 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
|
||||
std::map<const Mod*, RelativeFiles> modFiles;
|
||||
for (const auto* mod : mods) modFiles[mod] = scan(mod->directory);
|
||||
|
||||
// What the store sells comes from the content cache, which the launcher installs.
|
||||
const bool storeKnown = content_cache::installed();
|
||||
// Disabled mods count too: their archives and map registration are placed at launch.
|
||||
auto fingerprint = merge_fingerprint(catalog, mods, modFiles);
|
||||
auto fingerprint = merge_fingerprint(catalog, mods, modFiles, storeKnown);
|
||||
for (const auto& mod : catalog.inactive)
|
||||
fingerprint += "\ninactive " + mod.name + " " + mod_fingerprint(mod.directory);
|
||||
if (options.live) {
|
||||
fs::remove(output / stamp_file, error);
|
||||
} else {
|
||||
if (auto previous = previous_merge(output, fingerprint)) return std::move(*previous);
|
||||
fs::remove_all(output, error);
|
||||
} else if (auto previous = previous_merge(output, fingerprint)) {
|
||||
return std::move(*previous);
|
||||
}
|
||||
// A mod that adds copies of store items is not loaded at all. Found before
|
||||
// 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;
|
||||
if (!options.live) fs::remove_all(output, error);
|
||||
|
||||
// Progress: each mod's archives, each superbundle, then the layout.
|
||||
std::set<std::string> distinctTocs;
|
||||
@@ -291,7 +316,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
|
||||
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);
|
||||
const auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report);
|
||||
auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report);
|
||||
// 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]);
|
||||
|
||||
for (const auto& relative : superbundles) {
|
||||
if (keepRoot && lower(relative) == root_level) {
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <tuple>
|
||||
|
||||
namespace dingosdk::mods::detail {
|
||||
namespace {
|
||||
@@ -32,6 +35,10 @@ struct Asset {
|
||||
fb::BundleAsset asset;
|
||||
fb::BundleFileInfo file;
|
||||
bool base{};
|
||||
const std::string* mod{}; // whose copy of the bundle it came in; null for the game's
|
||||
// For a chunk: where its record is in the chunk metadata of the copy this version
|
||||
// of it came in, and where the game's copy lists the chunk (merge_chunk_metadata).
|
||||
ChunkRecord record;
|
||||
};
|
||||
|
||||
// Where one contributor's copy of an asset lives, so conflicting copies can be
|
||||
@@ -68,6 +75,16 @@ std::string asset_key(const fb::BundleAsset& asset) {
|
||||
(asset.kind == fb::AssetKind::chunk ? asset.guid.string() : lower(asset.name));
|
||||
}
|
||||
|
||||
// The game's item lists (items/itemmastercollection, a season's
|
||||
// 0.30.0_itemcollection), which every cosmetic mod adds its items to. The game
|
||||
// reads each entry of one at startup without checking it, so an entry naming an
|
||||
// item that is not loaded is a crash on every launch, not a missing item.
|
||||
bool item_list_name(std::string_view name) {
|
||||
const auto text = lower(name);
|
||||
return text.starts_with("items/") && text.find("collection") != std::string::npos;
|
||||
}
|
||||
constexpr std::string_view item_list_type = "delmaritemcollectionasset";
|
||||
|
||||
// Some bundles carry no inline manifest: their region only lists placements,
|
||||
// so their assets cannot be named and the bundle can only be passed through
|
||||
// whole, with the highest-priority copy winning.
|
||||
@@ -81,7 +98,13 @@ struct BundleState {
|
||||
std::uint32_t manifestChunk{};
|
||||
std::vector<Asset> assets;
|
||||
std::vector<fb::BundleFileInfo> files;
|
||||
std::vector<std::byte> metadata;
|
||||
// Each copy's chunk metadata; copies that carry the same list share an entry.
|
||||
// Every chunk remembers its record in one of these (Asset::record), and the
|
||||
// list is written again when the manifest is rebuilt (merge_chunk_metadata).
|
||||
std::vector<std::vector<std::byte>> metadata;
|
||||
// Which of those is the game's own, and the chunks the game's copy lists.
|
||||
std::size_t gameMetadata{ChunkRecord::none};
|
||||
std::vector<fb::Guid> shippedChunks;
|
||||
// Keyed position of each asset, so replacing one stays constant time: these
|
||||
// bundles hold tens of thousands of entries.
|
||||
std::map<std::string, std::size_t, std::less<>> seen;
|
||||
@@ -90,6 +113,78 @@ struct BundleState {
|
||||
// The base's own sha1 for each asset, so a mod that merely carries an
|
||||
// unchanged copy can be told apart from one that changed it.
|
||||
std::map<std::string, fb::Sha1, std::less<>> baseSha;
|
||||
// Assets a mod added that a higher-priority mod's different asset of the
|
||||
// same name then replaced: the key, and the sha1 of the copy that went.
|
||||
std::vector<std::pair<std::string, fb::Sha1>> displaced;
|
||||
};
|
||||
|
||||
// The guids of the EBX a merged bundle ends up holding, to tell whether what a
|
||||
// list entry names is in it. Read on first use: the assets mods added, which is
|
||||
// where a mod's entry nearly always points, and the game's own thousands only
|
||||
// when one of those is not the answer.
|
||||
class HeldAssets final {
|
||||
public:
|
||||
using Decode = std::function<std::vector<std::byte>(const Contribution&)>;
|
||||
HeldAssets(const BundleState& state, Decode decode) : state_(state), decode_(std::move(decode)) {}
|
||||
|
||||
[[nodiscard]] bool holds(const fb::Guid& guid) {
|
||||
if (!added_) { read(false); added_ = true; }
|
||||
if (guids_.contains(guid)) return true;
|
||||
if (!shipped_ && !unsure_) { read(true); shipped_ = true; }
|
||||
// A copy that could not be read may be the very one asked about.
|
||||
return unsure_ || guids_.contains(guid);
|
||||
}
|
||||
|
||||
// The asset that took the place of the one with this guid, when it was a
|
||||
// mod's addition another mod's of the same name replaced; null otherwise.
|
||||
[[nodiscard]] const Asset* replacement(const fb::Guid& guid) {
|
||||
if (!displacedRead_) {
|
||||
displacedRead_ = true;
|
||||
for (const auto& [key, sha1] : state_.displaced) {
|
||||
const auto copies = state_.history.find(key);
|
||||
const auto at = state_.seen.find(key);
|
||||
if (copies == state_.history.end() || at == state_.seen.end()) continue;
|
||||
for (const auto& copy : copies->second) {
|
||||
if (copy.sha1 != sha1) continue;
|
||||
try {
|
||||
displaced_.emplace(fb::ebx::read_file_guid(decode_(copy)), &state_.assets[at->second]);
|
||||
} catch (const std::exception&) {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto found = displaced_.find(guid);
|
||||
return found == displaced_.end() ? nullptr : found->second;
|
||||
}
|
||||
|
||||
private:
|
||||
void read(bool shipped) {
|
||||
for (const auto& asset : state_.assets) {
|
||||
if (asset.asset.kind != fb::AssetKind::ebx) continue;
|
||||
const auto key = asset_key(asset.asset);
|
||||
if (state_.baseSha.contains(key) != shipped) continue;
|
||||
// The copy the bundle keeps. One combined from several mods' is none
|
||||
// of theirs, and has the guid of the game's it was combined onto.
|
||||
const Contribution* kept{};
|
||||
if (const auto copies = state_.history.find(key); copies != state_.history.end())
|
||||
for (const auto& copy : copies->second) {
|
||||
if (copy.sha1 == asset.asset.sha1) { kept = © break; }
|
||||
if (copy.base) kept = ©
|
||||
}
|
||||
try {
|
||||
if (!kept) throw std::runtime_error("no copy to read");
|
||||
guids_.insert(fb::ebx::read_file_guid(decode_(*kept)));
|
||||
} catch (const std::exception&) {
|
||||
unsure_ = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BundleState& state_;
|
||||
Decode decode_;
|
||||
std::set<fb::Guid> guids_;
|
||||
std::map<fb::Guid, const Asset*> displaced_;
|
||||
bool added_{}, shipped_{}, unsure_{}, displacedRead_{};
|
||||
};
|
||||
|
||||
// Rebuilds the manifest and the matching placement list in the order the
|
||||
@@ -110,8 +205,115 @@ void build_manifest(const std::vector<Asset>& assets, std::span<const std::byte>
|
||||
emit(fb::AssetKind::chunk, manifest.chunks);
|
||||
}
|
||||
|
||||
std::span<const unsigned char> unsigned_bytes(std::span<const std::byte> data) {
|
||||
return {reinterpret_cast<const unsigned char*>(data.data()), data.size()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks, std::size_t game,
|
||||
std::span<const fb::Guid> shipped) {
|
||||
// A bundle with no chunks has nothing to describe; what the first copy has stays.
|
||||
if (chunks.empty()) return lists.empty() ? std::vector<std::byte>{} : lists.front();
|
||||
const bool shippedList = game < lists.size() && !lists[game].empty();
|
||||
|
||||
// Nothing moved: nearly every bundle is this, and its list is not read at all.
|
||||
// With the game's list, every chunk is the game's own where the game lists it;
|
||||
// without one, every chunk keeps one copy's record at its own place.
|
||||
const auto first = shippedList ? game : chunks.front().copy;
|
||||
bool whole = first < lists.size() && (!shippedList || chunks.size() == shipped.size());
|
||||
for (std::size_t index = 0; whole && index < chunks.size(); ++index)
|
||||
whole = chunks[index].copy == first && chunks[index].index == index &&
|
||||
(!shippedList || chunks[index].shipped == index);
|
||||
if (whole) return lists[first];
|
||||
|
||||
std::vector<std::optional<native_db::Node>> read(lists.size());
|
||||
const auto list = [&](std::size_t copy) -> const native_db::Node* {
|
||||
if (copy >= lists.size() || lists[copy].empty()) return nullptr;
|
||||
// A level's root bundle has thousands of chunks; every value takes a byte at least.
|
||||
if (!read[copy])
|
||||
read[copy] = native_db::read(unsigned_bytes(lists[copy]), "chunk metadata", nullptr,
|
||||
{.unique_fields = false, .max_entries = lists[copy].size()});
|
||||
return &*read[copy];
|
||||
};
|
||||
const auto row = [&](std::size_t copy, std::size_t index) -> const native_db::Node* {
|
||||
const auto* from = list(copy);
|
||||
return from && index < from->children.size() ? &from->children[index] : nullptr;
|
||||
};
|
||||
|
||||
// The game's list, when it is the kind this is written as: a record for each of
|
||||
// the game's chunks, in the order of their guids.
|
||||
const auto* original = shippedList ? list(game) : nullptr;
|
||||
if (original && original->children.size() != shipped.size()) original = nullptr;
|
||||
std::vector<std::size_t> place(shipped.size()); // where each of the game's chunks has its record
|
||||
if (original) {
|
||||
std::vector<std::size_t> order(shipped.size());
|
||||
for (std::size_t index = 0; index < order.size(); ++index) order[index] = index;
|
||||
std::ranges::sort(order, [&](std::size_t left, std::size_t right) { return shipped[left] < shipped[right]; });
|
||||
for (std::size_t at = 0; at < order.size(); ++at) place[order[at]] = at;
|
||||
}
|
||||
|
||||
std::vector<native_db::Node> records;
|
||||
std::vector<bool> described(chunks.size());
|
||||
records.reserve(chunks.size());
|
||||
for (std::size_t index = 0; index < chunks.size(); ++index) {
|
||||
const auto& chunk = chunks[index];
|
||||
const auto* written = row(chunk.copy, chunk.index);
|
||||
const native_db::Node* kept = written;
|
||||
std::optional<native_db::Node> changed;
|
||||
if (original && chunk.shipped < shipped.size()) {
|
||||
kept = &original->children[place[chunk.shipped]];
|
||||
// A mod's version of the chunk: its tool started from the game's list and wrote a
|
||||
// record where the chunk is listed, in place of whatever the game has there. The
|
||||
// first mip it gave is the one the mod's texture has; the rest of it is not the
|
||||
// chunk's (the hash is another chunk's, or empty).
|
||||
const auto* before = chunk.index < original->children.size() ? &original->children[chunk.index] : nullptr;
|
||||
if (chunk.copy != game && written && (!before || native_db::write(*written) != native_db::write(*before)))
|
||||
if (const auto* meta = written->field("meta"))
|
||||
if (const auto* mip = meta->field("firstMip")) {
|
||||
changed = *kept;
|
||||
if (auto* own = changed->field("meta")) {
|
||||
if (auto* current = own->field("firstMip")) *current = *mip;
|
||||
else own->children.push_back(*mip);
|
||||
kept = &*changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
described[index] = kept != nullptr;
|
||||
records.push_back(kept ? *kept : native_db::Node{});
|
||||
}
|
||||
const native_db::Node* shape = original;
|
||||
for (std::size_t copy = 0; !shape && copy < read.size(); ++copy)
|
||||
if (read[copy]) shape = &*read[copy];
|
||||
if (!shape) return {}; // no copy has a list at all
|
||||
// A chunk no list describes still takes up its place, with a record that says nothing.
|
||||
native_db::Node blank;
|
||||
blank.type = 2;
|
||||
for (std::size_t index = 0; index < records.size(); ++index)
|
||||
if (described[index]) {
|
||||
blank = records[index];
|
||||
blank.children.clear();
|
||||
break;
|
||||
}
|
||||
for (std::size_t index = 0; index < records.size(); ++index)
|
||||
if (!described[index]) records[index] = blank;
|
||||
|
||||
// As the game writes it: by the chunks' guids. Only a bundle whose own list in the
|
||||
// game is of another kind keeps the order its chunks are listed in.
|
||||
std::vector<std::size_t> order(chunks.size());
|
||||
for (std::size_t index = 0; index < order.size(); ++index) order[index] = index;
|
||||
if (original || !shippedList)
|
||||
std::ranges::stable_sort(order, [&](std::size_t left, std::size_t right) { return chunks[left].guid < chunks[right].guid; });
|
||||
auto merged = *shape;
|
||||
merged.children.clear();
|
||||
merged.children.reserve(records.size());
|
||||
for (const auto index : order) merged.children.push_back(std::move(records[index]));
|
||||
const auto bytes = native_db::write(merged);
|
||||
const auto* data = reinterpret_cast<const std::byte*>(bytes.data());
|
||||
return {data, data + bytes.size()};
|
||||
}
|
||||
|
||||
// One superbundle, combined across every mod that ships it. `used` collects the
|
||||
// patch archives the result actually references, keyed by install chunk, so the
|
||||
// layout declares those and only those.
|
||||
@@ -147,6 +349,26 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
std::string example;
|
||||
};
|
||||
std::map<std::string, ArchiveFault> archiveFaults;
|
||||
// Mods whose added assets went into a copy here: their added chunks come too.
|
||||
std::set<std::string> addedFrom;
|
||||
// The mods absorbed so far, so each asset can say whose copy it is.
|
||||
std::set<std::string> owners;
|
||||
// Every EBX a mod added to one of the game's bundles, by its document's guid
|
||||
// and with the bundle it was added to: what a changed list names is looked
|
||||
// up here, whichever bundle the mod put it in.
|
||||
std::multimap<fb::Guid, std::pair<const std::string*, const AssetAddition*>> addedByGuid;
|
||||
// The resource a mod added to a bundle under a name, by bundle, mod and name.
|
||||
std::map<std::tuple<std::string, std::string, std::string>, const AssetAddition*> companions;
|
||||
for (const auto& [bundleName, list] : overrides.added)
|
||||
for (const auto& addition : list) {
|
||||
if (addition.asset.kind == fb::AssetKind::ebx)
|
||||
addedByGuid.emplace(addition.file, std::pair{&bundleName, &addition});
|
||||
else if (addition.asset.kind == fb::AssetKind::resource)
|
||||
companions.emplace(std::tuple{bundleName, addition.mod, lower(addition.asset.name)}, &addition);
|
||||
}
|
||||
// Where an addition's payload already went in this superbundle's patch
|
||||
// archive, by install chunk: every copy that is given it reads the one.
|
||||
std::map<std::pair<std::uint32_t, const AssetAddition*>, fb::BundleFileInfo> carriedAt;
|
||||
|
||||
const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement,
|
||||
bool isBase, const fs::path& root) {
|
||||
@@ -296,43 +518,136 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
// The mod's unchanged copies of assets another mod changed: the change
|
||||
// goes into the merged patch, next to this bundle's other files.
|
||||
std::map<std::size_t, fb::BundleFileInfo> overridden;
|
||||
if (propagate && casBacked) {
|
||||
// The documents those changes refer to: the ones a mod added come along below.
|
||||
std::set<fb::Guid> wanted;
|
||||
if (propagate && (casBacked || !region.inlineManifest.empty())) {
|
||||
std::string example;
|
||||
for (std::size_t index = 0; index < casManifest.ebx.size(); ++index) {
|
||||
auto& asset = casManifest.ebx[index];
|
||||
const auto named = overrides.find(lower(asset.name));
|
||||
if (named == overrides.end()) continue;
|
||||
const auto change = named->second.find(asset.sha1);
|
||||
const auto at = 1 + index;
|
||||
if (change == named->second.end() || change->second.mod == modName || at >= region.files.size())
|
||||
continue;
|
||||
try {
|
||||
overridden[at] = store.write(region.files[at].location.installChunk, manifestArchive,
|
||||
change->second.encoded);
|
||||
asset.sha1 = change->second.sha1;
|
||||
asset.originalSize = change->second.originalSize;
|
||||
if (example.empty()) example = asset.name + " from " + change->second.mod;
|
||||
} catch (const std::exception& error) {
|
||||
report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name +
|
||||
" kept the game's copy, the change could not be copied (" + error.what() + ")");
|
||||
auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest);
|
||||
const auto replace = [&](std::vector<fb::BundleAsset>& assets, const auto& changes, std::size_t first) {
|
||||
for (std::size_t index = 0; index < assets.size(); ++index) {
|
||||
auto& asset = assets[index];
|
||||
const auto named = changes.find(lower(asset.name));
|
||||
if (named == changes.end()) continue;
|
||||
const auto change = named->second.find(asset.sha1);
|
||||
const auto at = (casBacked ? 1 : 0) + first + index;
|
||||
if (change == named->second.end() || change->second.mod == modName || at >= region.files.size())
|
||||
continue;
|
||||
const auto& resource = change->second.resource;
|
||||
if (asset.kind == fb::AssetKind::resource &&
|
||||
(!resource || resource->resourceType != asset.resourceType || resource->resourceId != asset.resourceId))
|
||||
continue;
|
||||
try {
|
||||
overridden[at] = store.write(region.files[at].location.installChunk, manifestArchive,
|
||||
change->second.encoded);
|
||||
asset.sha1 = change->second.sha1;
|
||||
asset.originalSize = change->second.originalSize;
|
||||
if (resource) asset.resourceMeta = resource->resourceMeta;
|
||||
wanted.insert(change->second.names.begin(), change->second.names.end());
|
||||
if (example.empty()) example = asset.name + " from " + change->second.mod;
|
||||
} catch (const std::exception& error) {
|
||||
report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name +
|
||||
" kept the game's copy, the change could not be copied (" + error.what() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
replace(manifest.ebx, overrides.changed, 0);
|
||||
replace(manifest.resources, overrides.scripts, manifest.ebx.size());
|
||||
if (casBacked) casManifest = std::move(manifest);
|
||||
else if (!overridden.empty()) region.inlineManifest = fb::write_binary_bundle(manifest);
|
||||
if (!overridden.empty())
|
||||
report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(overridden.size()) +
|
||||
" asset(s) take another mod's change, e.g. " + example);
|
||||
}
|
||||
// Assets another mod added to the game's copy of this bundle: this copy
|
||||
// loads instead of the game's on this mod's levels, so it gets them too.
|
||||
std::vector<Asset> additions;
|
||||
if (propagate && casBacked) {
|
||||
std::vector<const AssetAddition*> take;
|
||||
std::set<const AssetAddition*> taking;
|
||||
// A copy in the TOC the adder ships itself merges with the adder's bundle and has
|
||||
// its assets already; only copies in other superbundles (maps) need them carried.
|
||||
const auto here = lower(relative);
|
||||
if (const auto found = overrides.added.find(key); found != overrides.added.end())
|
||||
for (const auto& addition : found->second) {
|
||||
if (addition.mod == modName || addition.asset.kind == fb::AssetKind::chunk ||
|
||||
addition.toc == here) continue;
|
||||
take.push_back(&addition);
|
||||
taking.insert(&addition);
|
||||
}
|
||||
// What the changes this copy took name. The game keeps a list in more
|
||||
// bundles than the one a mod added to (the music playlist is in eight),
|
||||
// and a copy of any of them takes the changed list: it needs what the
|
||||
// mod added next to that list, and what those refer to in turn.
|
||||
for (std::vector<fb::Guid> open(wanted.begin(), wanted.end()); !open.empty();) {
|
||||
const auto guid = open.back();
|
||||
open.pop_back();
|
||||
const auto [first, last] = addedByGuid.equal_range(guid);
|
||||
for (auto at = first; at != last; ++at) {
|
||||
const auto& [from, addition] = at->second;
|
||||
if ((*from == key && addition->toc == here) || !taking.insert(addition).second) continue;
|
||||
take.push_back(addition);
|
||||
for (const auto& name : addition->names)
|
||||
if (wanted.insert(name).second) open.push_back(name);
|
||||
// The resource added under the same name goes where the asset goes.
|
||||
if (const auto companion = companions.find({*from, addition->mod, lower(addition->asset.name)});
|
||||
companion != companions.end() && taking.insert(companion->second).second)
|
||||
take.push_back(companion->second);
|
||||
}
|
||||
}
|
||||
if (!take.empty()) {
|
||||
std::set<std::string, std::less<>> present;
|
||||
for (const auto& asset : casManifest.ebx) present.insert(asset_key(asset));
|
||||
for (const auto& asset : casManifest.resources) present.insert(asset_key(asset));
|
||||
const auto installChunk = region.files.front().location.installChunk;
|
||||
for (const auto* addition : take) {
|
||||
if (!present.insert(asset_key(addition->asset)).second) continue;
|
||||
try {
|
||||
auto placed = carriedAt.find({installChunk, addition});
|
||||
if (placed == carriedAt.end())
|
||||
placed = carriedAt.emplace(std::pair{installChunk, addition},
|
||||
store.write(installChunk, manifestArchive, addition->encoded)).first;
|
||||
additions.push_back({addition->asset, placed->second});
|
||||
addedFrom.insert(addition->mod);
|
||||
} catch (const std::exception& error) {
|
||||
report.notes.push_back(modName + ": " + bundle.name + ": " + addition->asset.name +
|
||||
" from " + addition->mod + " could not be added (" + error.what() + ")");
|
||||
}
|
||||
}
|
||||
if (!additions.empty())
|
||||
report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(additions.size()) +
|
||||
" asset(s) added by other mods, e.g. " + additions.front().asset.name);
|
||||
}
|
||||
}
|
||||
// Nothing moved in this bundle: it passes through whole, as before.
|
||||
if (casBacked && !shared && !gridBundle && renumbered.empty() && overridden.empty()) casBacked = false;
|
||||
if (casBacked && !shared && !gridBundle && renumbered.empty() && overridden.empty() && additions.empty())
|
||||
casBacked = false;
|
||||
|
||||
for (auto& file : region.files) {
|
||||
store.shift(file.location, file.offset, placement);
|
||||
if (file.location.patch) used.emplace(file.location.installChunk, file.location.archive);
|
||||
}
|
||||
// Which of the region's files now lie in the merged patch rather than in this
|
||||
// mod's own archives, by position. Kept in step with region.files: an addition
|
||||
// below moves every later file along, and the pass over the bundle's assets
|
||||
// further down asks by where a file is then, not where it was.
|
||||
std::vector<bool> inPatch(region.files.size());
|
||||
for (const auto* moved : {&renumbered, &overridden})
|
||||
for (const auto& [index, file] : *moved) {
|
||||
region.files[index] = file;
|
||||
inPatch[index] = true;
|
||||
used.emplace(file.location.installChunk, file.location.archive);
|
||||
}
|
||||
// Added EBX go after this copy's own EBX and added resources after its own
|
||||
// resources, ahead of its chunks; after the indexed moves above so their
|
||||
// positions still hold.
|
||||
for (auto& addition : additions) {
|
||||
const auto ebx = addition.asset.kind == fb::AssetKind::ebx;
|
||||
const auto at = static_cast<std::ptrdiff_t>(1 + casManifest.ebx.size() + (ebx ? 0 : casManifest.resources.size()));
|
||||
(ebx ? casManifest.ebx : casManifest.resources).push_back(std::move(addition.asset));
|
||||
region.files.insert(region.files.begin() + at, addition.file);
|
||||
inPatch.insert(inPatch.begin() + at, true);
|
||||
used.emplace(addition.file.location.installChunk, addition.file.location.archive);
|
||||
}
|
||||
const auto fresh = !states.contains(key);
|
||||
if (fresh) { order.push_back(bundle.name); states.emplace(key, BundleState{}); }
|
||||
auto& state = states.at(key);
|
||||
@@ -362,6 +677,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
state.opaqueWhy = manifestFailure;
|
||||
state.files = std::move(region.files);
|
||||
state.assets.clear(); state.seen.clear();
|
||||
state.metadata.clear(); state.shippedChunks.clear();
|
||||
state.gameMetadata = ChunkRecord::none;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -381,20 +698,46 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
}
|
||||
if (casBacked) state.casBacked = true;
|
||||
if (!region.files.empty()) state.manifestChunk = region.files.front().location.installChunk;
|
||||
if (fresh || state.metadata.empty()) state.metadata = manifest.chunkMetadata;
|
||||
// Each chunk remembers where this copy lists it, and with that its record
|
||||
// in this copy's chunk metadata, whichever version of the chunk is kept.
|
||||
{
|
||||
auto copy = ChunkRecord::none;
|
||||
if (!manifest.chunkMetadata.empty()) {
|
||||
auto list = std::find(state.metadata.begin(), state.metadata.end(), manifest.chunkMetadata);
|
||||
if (list == state.metadata.end()) list = state.metadata.insert(state.metadata.end(), manifest.chunkMetadata);
|
||||
copy = static_cast<std::size_t>(list - state.metadata.begin());
|
||||
}
|
||||
if (isBase) {
|
||||
state.gameMetadata = copy;
|
||||
state.shippedChunks.clear();
|
||||
for (const auto& chunk : manifest.chunks) state.shippedChunks.push_back(chunk.guid);
|
||||
}
|
||||
const auto firstChunk = manifest.ebx.size() + manifest.resources.size();
|
||||
for (std::size_t index = 0; index < manifest.chunks.size() && firstChunk + index < flat.size(); ++index) {
|
||||
auto& record = flat[firstChunk + index].record;
|
||||
record.guid = manifest.chunks[index].guid;
|
||||
record.copy = copy;
|
||||
record.index = index;
|
||||
if (isBase) record.shipped = index;
|
||||
}
|
||||
}
|
||||
const std::size_t firstFile = casBacked ? 1 : 0;
|
||||
const auto* owner = isBase ? nullptr : &*owners.insert(modName).first;
|
||||
// By the mod whose added assets this copy's replace: how many, and one of them.
|
||||
std::map<std::string, std::pair<std::size_t, std::string>> replaced;
|
||||
for (std::size_t position = 0; position < flat.size(); ++position) {
|
||||
auto& entry = flat[position];
|
||||
entry.base = isBase;
|
||||
entry.mod = owner;
|
||||
auto id = asset_key(entry.asset);
|
||||
// A copy rewritten above (another mod's change, renumbered collision)
|
||||
// is not this mod's edit and no longer lies in this mod's archives: it
|
||||
// is in the merged patch. Recorded as a contribution it was read back
|
||||
// from the mod's folder at the patch's offset, the read failed, and
|
||||
// the assets that really had two edits (the item collections every
|
||||
// cosmetic mod adds its items to) fell back to one mod's copy.
|
||||
const bool rewritten = renumbered.contains(firstFile + position) ||
|
||||
overridden.contains(firstFile + position);
|
||||
// or added from another mod is not this mod's edit and does not lie in
|
||||
// this mod's archives: it is in the merged patch. Recorded as a
|
||||
// contribution it was read back from the mod's folder at the patch's
|
||||
// offset, the read failed, and the assets that really had two edits
|
||||
// (the item collections every cosmetic mod adds its items to) fell back
|
||||
// to one mod's copy.
|
||||
const bool rewritten = firstFile + position < inPatch.size() && inPatch[firstFile + position];
|
||||
if (!rewritten && (entry.asset.kind == fb::AssetKind::ebx ||
|
||||
entry.asset.kind == fb::AssetKind::resource)) {
|
||||
// The contributor's own copy still sits at its original offset in
|
||||
@@ -431,9 +774,26 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
previous.asset.sha1 != entry.asset.sha1)
|
||||
report.notes.push_back("contested chunk " + entry.asset.name +
|
||||
": kept the highest-priority copy");
|
||||
// Two mods each adding a different asset under one name: a bundle
|
||||
// holds one asset per name, so the lower-priority mod's is gone,
|
||||
// and whatever of that mod's still names it by guid finds nothing
|
||||
// (an item list entry is dropped for that further down).
|
||||
if (entry.asset.kind != fb::AssetKind::chunk && !rewritten && previous.mod &&
|
||||
previous.mod != owner && previous.asset.sha1 != entry.asset.sha1 &&
|
||||
!state.baseSha.contains(at->first)) {
|
||||
auto& [count, example] = replaced[*previous.mod];
|
||||
if (!count++) example = entry.asset.name;
|
||||
state.displaced.emplace_back(at->first, previous.asset.sha1);
|
||||
}
|
||||
// Whichever version is kept, it is still the chunk the game lists there.
|
||||
entry.record.shipped = previous.record.shipped;
|
||||
state.assets[at->second] = std::move(entry);
|
||||
}
|
||||
}
|
||||
for (const auto& [other, clash] : replaced)
|
||||
report.notes.push_back(other + ": " + bundle.name + ": " + std::to_string(clash.first) +
|
||||
" added asset(s) share a name with ones " + modName + " adds, e.g. " + clash.second +
|
||||
"; the merged bundle keeps " + modName + "'s");
|
||||
};
|
||||
|
||||
if (base) for (const auto& bundle : base->bundles) absorb(bundle, nullptr, true, baseRoot);
|
||||
@@ -449,6 +809,15 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
if (state.opaque) {
|
||||
region = fb::write_bundle_region(state.files, {});
|
||||
} else {
|
||||
// A placement that is not a patch one still lives in the
|
||||
// base install, whichever mod's copy referenced it.
|
||||
const auto decode = [&](const Contribution& from) {
|
||||
return fb::decode_cas(
|
||||
store.read(from.file.location.patch ? from.root : baseRoot,
|
||||
from.file.location, from.file.offset, from.file.size),
|
||||
{gameRoot});
|
||||
};
|
||||
HeldAssets held(state, decode);
|
||||
// Two mods rewriting one EBX asset is the case layering can never
|
||||
// answer: whichever copy wins, the other mod's additions are gone.
|
||||
// Combining the documents is the only shape that keeps both.
|
||||
@@ -474,16 +843,11 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
for (const auto& contribution : found->second)
|
||||
if (!contribution.base && contribution.sha1 != baseCopy->sha1)
|
||||
edits.push_back(&contribution);
|
||||
if (edits.size() < 2) continue;
|
||||
// An item list is looked at even when one mod alone changed it:
|
||||
// what its entries name has to be in the bundle either way.
|
||||
const auto named = entry.asset.kind == fb::AssetKind::ebx && item_list_name(entry.asset.name);
|
||||
if (edits.size() < 2 && !(named && edits.size() == 1)) continue;
|
||||
try {
|
||||
// A placement that is not a patch one still lives in the
|
||||
// base install, whichever mod's copy referenced it.
|
||||
const auto decode = [&](const Contribution& from) {
|
||||
return fb::decode_cas(
|
||||
store.read(from.file.location.patch ? from.root : baseRoot,
|
||||
from.file.location, from.file.offset, from.file.size),
|
||||
{gameRoot});
|
||||
};
|
||||
const auto baseBytes = decode(*baseCopy);
|
||||
std::vector<std::vector<std::byte>> editBytes;
|
||||
for (const auto* edit : edits) editBytes.push_back(decode(*edit));
|
||||
@@ -533,6 +897,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
what += ", " + std::to_string(table.conflicts) + " disagreed";
|
||||
} else {
|
||||
const auto baseDocument = fb::ebx::read_document(baseBytes);
|
||||
const auto itemList = lower(baseDocument.rootType) == item_list_type;
|
||||
if (edits.size() < 2 && !itemList) continue;
|
||||
// A material grid is a square interaction matrix whose rows are
|
||||
// addressed by slot numbers baked into each map's collision.
|
||||
// Two mods that each appended surfaces both claim the same
|
||||
@@ -550,12 +916,84 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
editDocuments.push_back(fb::ebx::read_document(bytes));
|
||||
std::vector<const fb::ebx::Document*> pointers;
|
||||
for (const auto& document : editDocuments) pointers.push_back(&document);
|
||||
// An item a mod lists is an asset that mod adds to this bundle.
|
||||
// When it is not here (another mod added a different asset
|
||||
// under its name, or the mod was built on top of a mod that
|
||||
// is not installed) the entry goes: the mod loses that item,
|
||||
// which it had already lost, and the game still starts.
|
||||
const fb::ebx::CarryImport inBundle = [&](const fb::ebx::ImportReference& item) {
|
||||
// What the game's own list names is the game's to answer for.
|
||||
return std::ranges::any_of(baseDocument.imports, [&](const fb::ebx::ImportReference& known) {
|
||||
return known.fileGuid == item.fileGuid; }) ||
|
||||
held.holds(item.fileGuid);
|
||||
};
|
||||
// Any other list loses an entry only when what it names was in
|
||||
// this bundle and another mod's asset of the same name took its
|
||||
// place: nothing else is known about where its entries live.
|
||||
const fb::ebx::CarryImport notReplaced = [&](const fb::ebx::ImportReference& named) {
|
||||
return !held.replacement(named.fileGuid) || held.holds(named.fileGuid);
|
||||
};
|
||||
fb::ebx::MergeSummary summary;
|
||||
auto combined = fb::ebx::merge_documents(baseDocument, pointers, &summary);
|
||||
if (!summary.instances && !summary.arrayEntries) continue;
|
||||
auto combined = fb::ebx::merge_documents(baseDocument, pointers, &summary,
|
||||
itemList ? inBundle : notReplaced);
|
||||
const auto mod_of = [&](std::size_t edit) { return path_utf8(edits[edit]->root.filename()); };
|
||||
if (summary.dropped.empty() && !summary.instances && !summary.arrayEntries) {
|
||||
// One mod's copy with nothing to leave out stays as that mod wrote it.
|
||||
if (edits.size() < 2) continue;
|
||||
// The same change from each of them is one change.
|
||||
if (std::ranges::all_of(edits, [&](const Contribution* edit) {
|
||||
return edit->sha1 == edits.front()->sha1; })) continue;
|
||||
// Nothing was added to it, so each mod changed what was there.
|
||||
// Values changed in place combine; when that is not all of
|
||||
// some mod's change, the copy already kept stays whole, and
|
||||
// the others' changes to it are not in the game.
|
||||
if (!summary.values || !summary.exact()) {
|
||||
std::set<std::string> others;
|
||||
for (std::size_t index = 0; index < edits.size(); ++index)
|
||||
if (edits[index]->sha1 != entry.asset.sha1) others.insert(mod_of(index));
|
||||
if (entry.mod) others.erase(*entry.mod);
|
||||
std::string names;
|
||||
for (const auto& other : others) names += (names.empty() ? "" : ", ") + other;
|
||||
if (!others.empty())
|
||||
report.notes.push_back(entry.asset.name + ": kept " +
|
||||
(entry.mod ? *entry.mod + "'s copy" : std::string("the highest-priority copy")) +
|
||||
" whole; the changes " + names + " made to it are of a kind that cannot be "
|
||||
"combined with it and are not in the game");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rebuilt = fb::ebx::write_document(combined);
|
||||
what = std::to_string(summary.instances) + " instance(s), " +
|
||||
std::to_string(summary.arrayEntries) + " list entries";
|
||||
if (summary.values) what += ", " + std::to_string(summary.values) + " changed value(s)";
|
||||
if (summary.contested) what += ", " + std::to_string(summary.contested) + " disagreed";
|
||||
if (summary.repeated)
|
||||
what += ", " + std::to_string(summary.repeated) + " repeated entry(ies) listed once";
|
||||
// The merged copy is the game's with what the mods added: a mod's
|
||||
// change to what was already there is in it only when it is one
|
||||
// that could be taken.
|
||||
for (const auto index : summary.uncarried)
|
||||
report.notes.push_back(mod_of(index) + ": " + entry.asset.name +
|
||||
": the merged copy has what this mod added to it, but not its changes to what "
|
||||
"the game's copy already held");
|
||||
std::map<std::size_t, std::vector<fb::Guid>> left; // by edit
|
||||
for (const auto& gone : summary.dropped) left[gone.edit].push_back(gone.target.fileGuid);
|
||||
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) {
|
||||
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 "
|
||||
"bundle does not hold them, e.g. " +
|
||||
(taken ? taken->asset.name + ", replaced by " + (taken->mod ? *taken->mod : "another mod") +
|
||||
"'s asset of the same name"
|
||||
: "the asset with guid " + guids.front().string() +
|
||||
", which no enabled mod adds to the bundle"));
|
||||
}
|
||||
}
|
||||
const auto placement = store.write(state.manifestChunk, manifestArchive,
|
||||
fb::encode_cas(rebuilt, {gameRoot}));
|
||||
@@ -563,6 +1001,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
entry.asset.sha1 = sha1_of(rebuilt);
|
||||
entry.asset.originalSize = rebuilt.size();
|
||||
used.emplace(placement.location.installChunk, placement.location.archive);
|
||||
if (edits.size() < 2) continue;
|
||||
report.notes.push_back(entry.asset.name + ": combined " +
|
||||
std::to_string(edits.size()) + " edits (" + what + ")");
|
||||
++report.mergedAssets;
|
||||
@@ -589,9 +1028,36 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
}
|
||||
}
|
||||
|
||||
// The merged bundle holds every mod's chunks, so its chunk metadata has
|
||||
// to describe them all, and in the game's own form: a record for each
|
||||
// chunk, in the order of the chunks' guids. With the game's list alone,
|
||||
// or with the lists the mods ship (which are not in that order, and
|
||||
// have lost the records their tools wrote over), the meshes and textures
|
||||
// of costumes built on one game costume never stream in.
|
||||
std::vector<std::byte> metadata;
|
||||
{
|
||||
std::vector<ChunkRecord> records;
|
||||
std::size_t fromMods{};
|
||||
for (const auto& entry : state.assets) {
|
||||
if (entry.asset.kind != fb::AssetKind::chunk) continue;
|
||||
records.push_back(entry.record);
|
||||
fromMods += entry.record.shipped == ChunkRecord::none || entry.record.copy != state.gameMetadata;
|
||||
}
|
||||
try {
|
||||
metadata = merge_chunk_metadata(state.metadata, records, state.gameMetadata, state.shippedChunks);
|
||||
if (fromMods && state.gameMetadata != ChunkRecord::none &&
|
||||
metadata != state.metadata[state.gameMetadata])
|
||||
report.notes.push_back(name + ": chunk metadata written again for " + std::to_string(records.size()) +
|
||||
" chunks, " + std::to_string(fromMods) + " of them added, changed or carried by mods");
|
||||
} catch (const std::exception& failure) {
|
||||
if (!state.metadata.empty()) metadata = state.metadata.front();
|
||||
report.notes.push_back(name + ": the copies' chunk metadata could not be put together (" + failure.what() +
|
||||
"); kept the first copy's, so chunks mods add to this bundle may not load");
|
||||
}
|
||||
}
|
||||
fb::BinaryBundle manifest;
|
||||
std::vector<fb::BundleFileInfo> files;
|
||||
build_manifest(state.assets, state.metadata, manifest, files);
|
||||
build_manifest(state.assets, metadata, manifest, files);
|
||||
if (!state.casBacked) {
|
||||
region = fb::write_bundle_region(files, fb::write_binary_bundle(manifest));
|
||||
} else {
|
||||
@@ -637,6 +1103,20 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
merged.chunks.push_back(chunk);
|
||||
}
|
||||
}
|
||||
// This superbundle resolves chunks from its own TOC: the audio of a song another
|
||||
// mod added to a bundle copied here has to be listed here as well.
|
||||
for (const auto& name : addedFrom) {
|
||||
const auto found = overrides.chunks.find(name);
|
||||
if (found == overrides.chunks.end()) continue;
|
||||
std::size_t listed{};
|
||||
for (const auto& chunk : found->second) {
|
||||
if (!chunkAt.emplace(chunk.guid, merged.chunks.size()).second) continue;
|
||||
merged.chunks.push_back(chunk);
|
||||
used.emplace(chunk.location.installChunk, chunk.location.archive);
|
||||
++listed;
|
||||
}
|
||||
if (listed) report.notes.push_back(relative + ": " + std::to_string(listed) + " chunk(s) from " + name);
|
||||
}
|
||||
|
||||
if (sources.size() > 1) {
|
||||
report.notes.push_back(relative + ": combined " + std::to_string(sources.size()) +
|
||||
|
||||
@@ -80,8 +80,11 @@ struct PlacementRecord {
|
||||
PlacementRecord read_placements(const fs::path& file);
|
||||
void write_placements(const fs::path& file, const PlacementRecord& record);
|
||||
|
||||
// `storeKnown`: the content cache that says what the game's store sells is
|
||||
// installed, so the mods were checked for copies of store items; a patch built
|
||||
// without it is built again once it is there.
|
||||
std::string merge_fingerprint(const Catalog& catalog, const std::vector<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles);
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles, bool storeKnown);
|
||||
inline constexpr wchar_t stamp_file[] = L"reskate-merge.stamp";
|
||||
std::optional<MergeReport> previous_merge(const fs::path& output, const std::string& fingerprint);
|
||||
void write_stamp(const fs::path& output, const std::string& fingerprint, const MergeReport& report);
|
||||
@@ -174,18 +177,93 @@ struct AssetOverride {
|
||||
fb::Sha1 sha1; // the mod's version
|
||||
std::uint64_t originalSize{};
|
||||
std::vector<std::byte> encoded; // its payload, as stored in cas
|
||||
// Present for a Lua resource replacement: its identity and metadata must
|
||||
// travel with the script bytes, including source and bytecode sizes.
|
||||
std::optional<fb::BundleAsset> resource;
|
||||
// The documents the changed EBX refers to that its mod added to the bundle it
|
||||
// made the change in, by their guids. They go wherever the change goes: the
|
||||
// game keeps a list in more bundles than one, each with what the list names.
|
||||
std::set<fb::Guid> names;
|
||||
};
|
||||
// By lower-case asset name, then the game's sha1 the change replaces.
|
||||
using AssetOverrides = std::map<std::string, std::map<fb::Sha1, AssetOverride>, std::less<>>;
|
||||
inline constexpr std::uint32_t luaScriptResourceType = 0xEC383B87;
|
||||
// An EBX asset a mod added to one of the game's bundles, or a resource it added
|
||||
// under the same name as such an asset (a wave's sound-bank). Maps carry their own
|
||||
// copy of a bundle like bam_coregameassets, and that copy loads instead of the
|
||||
// game's on their levels; an edit to a list there (the master music playlist)
|
||||
// propagates as a change, so the asset it now names has to come along too. The
|
||||
// game also keeps such a list in more bundles than the one the mod put its
|
||||
// assets in, and a copy of any of those takes the change the same way.
|
||||
struct AssetAddition {
|
||||
std::string mod;
|
||||
fb::BundleAsset asset;
|
||||
std::vector<std::byte> encoded; // its payload, as stored in cas
|
||||
// The lower-case relative path of the TOC the mod ships this bundle in. A copy of the
|
||||
// bundle in that same TOC merges with the mod's own bundle and already has the asset;
|
||||
// only copies in other superbundles (a map's level TOC) need it carried.
|
||||
std::string toc;
|
||||
// For an EBX: its document's guid, which is what a list names it by, and the
|
||||
// guids of the documents it refers to in turn (a song names its wave).
|
||||
fb::Guid file;
|
||||
std::vector<fb::Guid> names;
|
||||
};
|
||||
struct AssetOverrides {
|
||||
// By lower-case asset name, then the game's sha1 the change replaces.
|
||||
std::map<std::string, std::map<fb::Sha1, AssetOverride>, std::less<>> changed;
|
||||
// Separate from EBX: a script's EBX wrapper can have the same asset name.
|
||||
std::map<std::string, std::map<fb::Sha1, AssetOverride>, std::less<>> scripts;
|
||||
// By lower-case bundle name, in priority order.
|
||||
std::map<std::string, std::vector<AssetAddition>, std::less<>> added;
|
||||
// By mod folder name: TOC chunks the mod adds that its carried additions name
|
||||
// (a new wave's audio). A map's superbundle resolves chunks from its own TOC,
|
||||
// so wherever the additions go these entries go too. Collected pointing into
|
||||
// the mod's own archives; the merge shifts them to where those archives landed.
|
||||
std::map<std::string, std::vector<fb::TocChunk>, std::less<>> chunks;
|
||||
|
||||
// The changes asset mods (mods that add no levels) make to the game's own EBX,
|
||||
// the highest-priority mod's change winning. Never throws: an unreadable mod
|
||||
// is noted and simply changes nothing elsewhere.
|
||||
[[nodiscard]] bool empty() const noexcept { return changed.empty() && scripts.empty() && added.empty(); }
|
||||
};
|
||||
|
||||
// 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.
|
||||
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles,
|
||||
const CasStore& store, const fs::path& baseRoot,
|
||||
const fs::path& gameRoot, MergeReport& report);
|
||||
|
||||
// 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
|
||||
// engine needs before it streams the chunk (a texture's first mip). In the game's
|
||||
// own bundles the records are in the order of the chunks' guids, not the order
|
||||
// the chunks are listed in. A mod's copy starts from the game's list as it is and
|
||||
// has, at the place each chunk it adds or replaces is listed at, a record its
|
||||
// tool wrote: for an added chunk that is the chunk's own record, but for a
|
||||
// replaced one it has taken the place of another chunk's (and older tools left
|
||||
// its hash empty), so the game's record for that other chunk is gone from the
|
||||
// mod's list.
|
||||
struct ChunkRecord {
|
||||
static constexpr std::size_t none = static_cast<std::size_t>(-1);
|
||||
fb::Guid guid;
|
||||
std::size_t copy{none}; // the list of the copy this version of the chunk came in; none when that copy has no list
|
||||
std::size_t index{}; // where that copy lists the chunk
|
||||
std::size_t shipped{none}; // where the game's copy lists it; none for a chunk mods add
|
||||
};
|
||||
// The list for a merged bundle, written as the game writes one: a record for each
|
||||
// of `chunks` (the bundle's chunks, as listed), in the order of their guids. A
|
||||
// chunk of the game's keeps the game's record for it, whichever copy carries it
|
||||
// (`game` is the game's list among `lists`, `shipped` the chunks the game's copy
|
||||
// lists); when the kept version is one a mod replaced it with, the first mip that
|
||||
// mod's record gives goes into it. A chunk a mod adds has that mod's record, and
|
||||
// one nothing describes gets a record that says nothing. In a bundle the game
|
||||
// does not ship, or ships without a list, every chunk is one a mod adds. When
|
||||
// every chunk is the game's own where the game lists it, the game's list comes
|
||||
// back as it is; so does one mod's, for a bundle without a list in the game that
|
||||
// is that mod's copy alone.
|
||||
// Throws when a list that is needed cannot be read.
|
||||
[[nodiscard]] std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks,
|
||||
std::size_t game = ChunkRecord::none,
|
||||
std::span<const fb::Guid> shipped = {});
|
||||
|
||||
fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
std::vector<Source>& sources, MergeReport& report,
|
||||
const std::string& relative, ArchiveUse& used, CasStore& store,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
#include "mod_merge_internal.h"
|
||||
#include "Engine/Resource/cas_codec.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
#include "Engine/Resource/ebx_merge.h"
|
||||
#include "Engine/Resource/ebx_writer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::mods::detail {
|
||||
@@ -17,6 +24,30 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
|
||||
const auto files = modFiles.find(mod);
|
||||
if (files == modFiles.end()) continue;
|
||||
std::size_t changed{};
|
||||
// This mod's new assets, and the partitions its recorded changes import.
|
||||
// An added asset only follows a change that refers to it: copied on its
|
||||
// own, it could arrive in another mod's bundle without the resources
|
||||
// and chunks it was added with.
|
||||
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
|
||||
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
|
||||
std::set<fb::Guid> imported;
|
||||
// The game's assets this mod changed. They wait until its additions are
|
||||
// settled: one that another mod's asset of the same name stands in for
|
||||
// is not where a copy can find it, and a change must not name it there.
|
||||
struct Change {
|
||||
std::string bundle; // lower case: where the mod changed it
|
||||
std::string name; // lower case
|
||||
std::string asset; // as the bundle spells it
|
||||
fb::Sha1 game; // the game's copy it replaces
|
||||
fb::Sha1 sha1;
|
||||
std::uint64_t originalSize{};
|
||||
std::vector<std::byte> encoded;
|
||||
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
|
||||
};
|
||||
std::vector<Change> changes;
|
||||
const auto decoded = [&](std::span<const std::byte> encoded) {
|
||||
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
|
||||
};
|
||||
for (const auto& relative : files->second.tocs) {
|
||||
std::error_code error;
|
||||
const auto baseToc = baseRoot / fs::path(relative);
|
||||
@@ -26,36 +57,244 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
|
||||
const auto game = fb::read_toc(read_file(baseToc));
|
||||
std::map<std::string, const fb::TocBundle*, std::less<>> gameBundles;
|
||||
for (const auto& bundle : game.bundles) gameBundles.emplace(lower(bundle.name), &bundle);
|
||||
std::set<fb::Guid> gameChunks;
|
||||
for (const auto& chunk : game.chunks) gameChunks.insert(chunk.guid);
|
||||
for (const auto& chunk : own.chunks)
|
||||
if (!chunk.removed && chunk.location.patch && !gameChunks.contains(chunk.guid)) newChunks.push_back(chunk);
|
||||
for (const auto& bundle : own.bundles) {
|
||||
const auto shipped = gameBundles.find(lower(bundle.name));
|
||||
if (shipped == gameBundles.end()) continue;
|
||||
const auto modListing = list_bundle(store, mod->directory, baseRoot, bundle, gameRoot);
|
||||
const auto gameListing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
|
||||
if (!modListing || !gameListing) continue;
|
||||
std::map<std::string, fb::Sha1, std::less<>> original;
|
||||
for (const auto& asset : gameListing->manifest.ebx) original.emplace(lower(asset.name), asset.sha1);
|
||||
// The game's copy of each asset: its sha1, and where in the listing it is.
|
||||
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
|
||||
for (std::size_t index = 0; index < gameListing->manifest.ebx.size(); ++index) {
|
||||
const auto& asset = gameListing->manifest.ebx[index];
|
||||
original.emplace(lower(asset.name), std::pair{asset.sha1, index});
|
||||
}
|
||||
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
|
||||
const auto& asset = modListing->manifest.ebx[index];
|
||||
const auto name = lower(asset.name);
|
||||
const auto game_copy = original.find(name);
|
||||
if (game_copy == original.end() || game_copy->second == asset.sha1) continue;
|
||||
auto& versions = out[name];
|
||||
if (versions.contains(game_copy->second)) continue;
|
||||
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
|
||||
const auto at = modListing->first + index;
|
||||
if (at >= modListing->files.size()) continue;
|
||||
const auto& file = modListing->files[at];
|
||||
versions.emplace(game_copy->second,
|
||||
const auto payload = [&] {
|
||||
return store.read(file.location.patch ? mod->directory : baseRoot,
|
||||
file.location, file.offset, file.size);
|
||||
};
|
||||
if (game_copy == original.end()) {
|
||||
candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}});
|
||||
continue;
|
||||
}
|
||||
auto encodedPayload = payload();
|
||||
try {
|
||||
for (const auto& reference : decoded(encodedPayload).imports)
|
||||
imported.insert(reference.fileGuid);
|
||||
} catch (const std::exception&) {}
|
||||
// A higher mod's version of the asset, which this change is
|
||||
// combined with below, may name what this mod adds.
|
||||
if (const auto versions = out.changed.find(name); versions != out.changed.end())
|
||||
if (const auto existing = versions->second.find(game_copy->second.first);
|
||||
existing != versions->second.end()) try {
|
||||
for (const auto& reference : decoded(existing->second.encoded).imports)
|
||||
imported.insert(reference.fileGuid);
|
||||
} catch (const std::exception&) {}
|
||||
std::optional<fb::BundleFileInfo> gameFile;
|
||||
if (const auto gameAt = gameListing->first + game_copy->second.second;
|
||||
gameAt < gameListing->files.size())
|
||||
gameFile = gameListing->files[gameAt];
|
||||
changes.push_back({lower(bundle.name), name, asset.name, game_copy->second.first, asset.sha1,
|
||||
asset.originalSize, std::move(encodedPayload), gameFile});
|
||||
}
|
||||
// Maps carry stock SkaterLoader too. Replacing it only in the game's
|
||||
// bundles loses custom cosmetic material targets on a map transition.
|
||||
// Other resource types retain their own table/dependency merge rules.
|
||||
std::map<std::string, const fb::BundleAsset*, std::less<>> gameScripts;
|
||||
for (const auto& asset : gameListing->manifest.resources)
|
||||
if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset);
|
||||
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
|
||||
const auto& asset = modListing->manifest.resources[index];
|
||||
if (asset.resourceType != luaScriptResourceType) continue;
|
||||
const auto name = lower(asset.name);
|
||||
const auto originalScript = gameScripts.find(name);
|
||||
if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 ||
|
||||
originalScript->second->resourceId != asset.resourceId) continue;
|
||||
auto& versions = out.scripts[name];
|
||||
if (versions.contains(originalScript->second->sha1)) continue;
|
||||
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
|
||||
if (at >= modListing->files.size()) continue;
|
||||
const auto& file = modListing->files[at];
|
||||
versions.emplace(originalScript->second->sha1,
|
||||
AssetOverride{mod->name, asset.sha1, asset.originalSize,
|
||||
store.read(file.location.patch ? mod->directory : baseRoot,
|
||||
file.location, file.offset, file.size)});
|
||||
store.read(file.location.patch ? mod->directory : baseRoot,
|
||||
file.location, file.offset, file.size), asset});
|
||||
++changed;
|
||||
}
|
||||
// Resources the mod adds, kept to go with an added EBX of the same name
|
||||
// (a wave's sound-bank resource registers the wave with the audio system).
|
||||
std::set<std::string, std::less<>> shippedResources;
|
||||
for (const auto& asset : gameListing->manifest.resources) shippedResources.insert(lower(asset.name));
|
||||
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
|
||||
const auto& asset = modListing->manifest.resources[index];
|
||||
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
|
||||
if (shippedResources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
|
||||
const auto& file = modListing->files[at];
|
||||
companions.push_back({lower(bundle.name), {mod->name, asset,
|
||||
store.read(file.location.patch ? mod->directory : baseRoot, file.location, file.offset, file.size), lower(relative)}});
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& failure) {
|
||||
report.notes.push_back(mod->name + ": " + relative +
|
||||
": its changes could not be read for other mods' copies (" + failure.what() + ")");
|
||||
}
|
||||
}
|
||||
// One addition per kind and name in a bundle, the highest-priority mod's. Two
|
||||
// mods adding the same asset is no clash; two different assets under one name
|
||||
// is, and only one of them can be what a copy gets: say whose, so a song or an
|
||||
// item that goes missing on a map can be traced to the mod that took its name.
|
||||
std::map<std::string, std::pair<std::size_t, std::string>, std::less<>> shadowed; // by the mod kept
|
||||
// This mod's added EBX that another mod's different document stands in for: what
|
||||
// names one by its guid finds nothing in a copy that was given the other's.
|
||||
std::set<fb::Guid> lost;
|
||||
const auto keep = [&](const std::string& bundle, AssetAddition addition) {
|
||||
auto& list = out.added[bundle];
|
||||
const auto name = lower(addition.asset.name);
|
||||
const auto holder = std::ranges::find_if(list, [&](const AssetAddition& other) {
|
||||
return other.asset.kind == addition.asset.kind && lower(other.asset.name) == name; });
|
||||
if (holder != list.end()) {
|
||||
if (holder->mod != addition.mod && holder->asset.sha1 != addition.asset.sha1) {
|
||||
auto& [count, example] = shadowed[holder->mod];
|
||||
if (!count++) example = addition.asset.name;
|
||||
if (addition.asset.kind == fb::AssetKind::ebx && holder->file != addition.file)
|
||||
lost.insert(addition.file);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
list.push_back(std::move(addition));
|
||||
return true;
|
||||
};
|
||||
// Transitively: an addition a following addition imports follows too (a new song
|
||||
// imports its new wave, and only the song is named by the changed playlist).
|
||||
std::vector<bool> followed(candidates.size());
|
||||
// The documents this mod added to each bundle, by guid.
|
||||
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn;
|
||||
for (std::size_t index = 0; index < candidates.size(); ++index) {
|
||||
auto& addition = candidates[index].second;
|
||||
try {
|
||||
const auto document = decoded(addition.encoded);
|
||||
addition.file = document.fileGuid;
|
||||
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
|
||||
addedIn[candidates[index].first].insert(addition.file);
|
||||
} catch (const std::exception&) { followed[index] = true; } // unreadable: never follows
|
||||
}
|
||||
for (bool grew = true; grew;) {
|
||||
grew = false;
|
||||
for (std::size_t index = 0; index < candidates.size(); ++index) {
|
||||
auto& [bundle, addition] = candidates[index];
|
||||
if (followed[index] || !imported.contains(addition.file)) continue;
|
||||
followed[index] = grew = true;
|
||||
imported.insert(addition.names.begin(), addition.names.end());
|
||||
keep(bundle, std::move(addition));
|
||||
}
|
||||
}
|
||||
for (auto& [bundle, companion] : companions) {
|
||||
// Looked up, not made: a bundle with nothing carried must stay out of `added`.
|
||||
const auto found = out.added.find(bundle);
|
||||
if (found == out.added.end()) continue;
|
||||
const auto name = lower(companion.asset.name);
|
||||
if (std::ranges::any_of(found->second, [&](const AssetAddition& addition) {
|
||||
return addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx &&
|
||||
lower(addition.asset.name) == name; }))
|
||||
keep(bundle, std::move(companion));
|
||||
}
|
||||
for (auto& change : changes) {
|
||||
// What the change names of the assets the mod added beside it. The mod
|
||||
// put them in one bundle with what names them, so they stay together.
|
||||
std::set<fb::Guid> names;
|
||||
const auto beside = addedIn.find(change.bundle);
|
||||
try {
|
||||
auto document = decoded(change.encoded);
|
||||
const auto stays = [&](const fb::ebx::ImportReference& reference) {
|
||||
return !lost.contains(reference.fileGuid); };
|
||||
if (!std::ranges::all_of(document.imports, stays))
|
||||
if (const auto gone = fb::ebx::drop_root_references(document, stays)) {
|
||||
const auto rebuilt = fb::ebx::write_document(document);
|
||||
change.sha1 = sha1_of(rebuilt);
|
||||
change.originalSize = rebuilt.size();
|
||||
change.encoded = fb::encode_cas(rebuilt, {gameRoot});
|
||||
report.notes.push_back(mod->name + ": " + change.asset + ": " + std::to_string(gone) +
|
||||
" entry(ies) left out of the copies other mods carry, where another mod's asset of "
|
||||
"the same name stands in for what they name");
|
||||
}
|
||||
if (beside != addedIn.end())
|
||||
for (const auto& reference : document.imports)
|
||||
if (beside->second.contains(reference.fileGuid) && stays(reference))
|
||||
names.insert(reference.fileGuid);
|
||||
} catch (const std::exception&) {}
|
||||
auto& versions = out.changed[change.name];
|
||||
const auto existing = versions.find(change.game);
|
||||
if (existing == versions.end()) {
|
||||
versions.emplace(change.game, AssetOverride{mod->name, change.sha1, change.originalSize,
|
||||
std::move(change.encoded), std::nullopt, std::move(names)});
|
||||
++changed;
|
||||
continue;
|
||||
}
|
||||
// The same change again, from another of the mod's bundles or another mod.
|
||||
if (existing->second.sha1 == change.sha1) {
|
||||
existing->second.names.insert(names.begin(), names.end());
|
||||
continue;
|
||||
}
|
||||
if (!change.gameFile) continue;
|
||||
try {
|
||||
const auto baseDoc = decoded(store.read(baseRoot, change.gameFile->location,
|
||||
change.gameFile->offset, change.gameFile->size));
|
||||
const auto existingDoc = decoded(existing->second.encoded);
|
||||
const auto thisDoc = decoded(change.encoded);
|
||||
// Lowest priority first, the order a bundle's own copies are combined
|
||||
// in: where two mods changed one value, the higher mod's is kept.
|
||||
const std::array<const fb::ebx::Document*, 2> editDocs{&thisDoc, &existingDoc};
|
||||
fb::ebx::MergeSummary summary;
|
||||
auto combined = fb::ebx::merge_documents(baseDoc, editDocs, &summary);
|
||||
// Changed values alone are only worth a copy that has all of them.
|
||||
if (!summary.instances && !summary.arrayEntries && !(summary.values && summary.exact())) continue;
|
||||
const auto rebuilt = fb::ebx::write_document(combined);
|
||||
existing->second.sha1 = sha1_of(rebuilt);
|
||||
existing->second.originalSize = rebuilt.size();
|
||||
existing->second.encoded = fb::encode_cas(rebuilt, {gameRoot});
|
||||
if (existing->second.mod != mod->name && !existing->second.mod.ends_with(" + " + mod->name))
|
||||
existing->second.mod += " + " + mod->name;
|
||||
existing->second.names.insert(names.begin(), names.end());
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
for (const auto& [other, clash] : shadowed)
|
||||
report.notes.push_back(mod->name + ": " + std::to_string(clash.first) +
|
||||
" added asset(s) share a name with ones " + other + " adds, e.g. " + clash.second +
|
||||
"; other mods' copies of the bundle get " + other + "'s");
|
||||
// The new TOC chunks this mod's carried EBX name (as raw GUID bytes, the way an
|
||||
// EBX stores a ChunkId), so they can follow those assets into other superbundles.
|
||||
if (!newChunks.empty()) {
|
||||
std::vector<std::vector<std::byte>> carried;
|
||||
for (const auto& [bundle, list] : out.added)
|
||||
for (const auto& addition : list)
|
||||
if (addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx) try {
|
||||
carried.push_back(fb::decode_cas(addition.encoded, {gameRoot}));
|
||||
} catch (const std::exception&) {}
|
||||
std::set<fb::Guid> taken;
|
||||
for (const auto& chunk : newChunks) {
|
||||
const auto& id = chunk.guid.bytes;
|
||||
if (!taken.contains(chunk.guid) && std::ranges::any_of(carried, [&](const std::vector<std::byte>& bytes) {
|
||||
return std::search(bytes.begin(), bytes.end(), id.begin(), id.end()) != bytes.end(); })) {
|
||||
out.chunks[mod->name].push_back(chunk);
|
||||
taken.insert(chunk.guid);
|
||||
}
|
||||
}
|
||||
if (!taken.empty())
|
||||
report.notes.push_back(mod->name + ": " + std::to_string(taken.size()) +
|
||||
" added chunk(s) follow its added assets into other mods' superbundles");
|
||||
}
|
||||
if (changed)
|
||||
report.notes.push_back(mod->name + ": " + std::to_string(changed) +
|
||||
" changed asset(s) also apply to the copies other mods carry");
|
||||
|
||||
@@ -45,9 +45,10 @@ fs::path sdk_module() {
|
||||
} // namespace
|
||||
|
||||
std::string merge_fingerprint(const Catalog& catalog, const std::vector<const Mod*>& mods,
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles) {
|
||||
const std::map<const Mod*, RelativeFiles>& modFiles, bool storeKnown) {
|
||||
std::string inputs = stamp_header;
|
||||
inputs += '\n';
|
||||
inputs += storeKnown ? "store known\n" : "store unknown\n";
|
||||
const auto describe = [&](std::string_view label, const fs::path& path) {
|
||||
inputs += label;
|
||||
std::error_code error;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "Engine/Vfs/mod_music.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::mods {
|
||||
namespace {
|
||||
bool text_ok(const std::string& value) {
|
||||
return !value.empty() && value.size() <= 255 &&
|
||||
std::none_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; });
|
||||
}
|
||||
}
|
||||
|
||||
bool music_artwork_path(const std::string& path) {
|
||||
if (!text_ok(path) || path.front() == '/' || path.find_first_of("\\:%?#") != std::string::npos ||
|
||||
!path.ends_with(".png")) return false;
|
||||
std::size_t start = 0;
|
||||
while (start < path.size()) {
|
||||
const auto end = path.find('/', start);
|
||||
const auto part = path.substr(start, end == std::string::npos ? end : end - start);
|
||||
if (part.empty() || part == "." || part == ".." || part.back() == ' ' || part.back() == '.') return false;
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<MusicPlaylist> parse_music_playlists(const std::string& mod, const std::string& json) {
|
||||
std::vector<MusicPlaylist> result;
|
||||
const auto root = Json::parse(json);
|
||||
if (!root.contains("schema") || root.at("schema").get<int>() != 1 || !root.contains("playlists") ||
|
||||
!root.at("playlists").is_array())
|
||||
throw std::runtime_error("expected schema 1 with a playlists array");
|
||||
const auto& list = root.at("playlists");
|
||||
std::map<std::string, std::string> covers;
|
||||
if (root.contains("song_artwork") && root.at("song_artwork").is_object()) {
|
||||
for (const auto& [id, value] : root.at("song_artwork").items()) {
|
||||
if (covers.size() >= 4096) break;
|
||||
if (text_ok(id) && value.is_string() && music_artwork_path(value.string())) covers.emplace(id, value.string());
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < list.size() && i < 64; ++i) {
|
||||
const auto& entry = list.at(i);
|
||||
if (!entry.contains("name") || !entry.at("name").is_string() || !text_ok(entry.at("name").string()) ||
|
||||
!entry.contains("songs") || !entry.at("songs").is_array())
|
||||
throw std::runtime_error("a playlist needs a name and a songs array");
|
||||
MusicPlaylist playlist{"mod:" + mod + ":" + entry.at("name").string(), entry.at("name").string(), {}};
|
||||
if (entry.contains("artwork") && entry.at("artwork").is_string() && music_artwork_path(entry.at("artwork").string()))
|
||||
playlist.artwork = entry.at("artwork").string();
|
||||
const auto& songs = entry.at("songs");
|
||||
for (std::size_t s = 0; s < songs.size() && s < 1024; ++s)
|
||||
if (songs.at(s).is_string() && text_ok(songs.at(s).string())) playlist.songs.push_back(songs.at(s).string());
|
||||
for (const auto& id : playlist.songs)
|
||||
if (const auto found = covers.find(id); found != covers.end()) playlist.song_artwork.emplace(*found);
|
||||
if (text_ok(playlist.id)) result.push_back(std::move(playlist));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dingosdk::mods
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::mods {
|
||||
|
||||
// A playlist a mod declares for the game's music screen. `songs` are song ids, the
|
||||
// "artist - title" the game registers a song under.
|
||||
struct MusicPlaylist {
|
||||
std::string id; // "mod:<mod name>:<playlist name>"
|
||||
std::string name; // shown in the music screen
|
||||
std::vector<std::string> songs;
|
||||
std::string artwork; // relative PNG path inside the mod; empty means no cover
|
||||
std::map<std::string, std::string> song_artwork;
|
||||
};
|
||||
|
||||
// Reads a mod's reskate-music.json:
|
||||
// {"schema":1,"playlists":[{"name":"...","songs":["artist - title", ...]}]}
|
||||
// Optional playlist "artwork" and root "song_artwork" (song-id -> PNG path) use mod-relative paths.
|
||||
// Invalid optional artwork is ignored, without dropping valid music membership.
|
||||
// Text must be non-empty, at most 255 bytes and free of control characters; a song entry that is
|
||||
// not such text is dropped, as are playlists beyond 64 and songs beyond 1024 per playlist.
|
||||
// Throws std::exception on a document that is not this shape, so the caller can skip the file.
|
||||
std::vector<MusicPlaylist> parse_music_playlists(const std::string& mod, const std::string& json);
|
||||
|
||||
// A portable mod-relative PNG path: no traversal, drive names or alternate streams.
|
||||
bool music_artwork_path(const std::string& path);
|
||||
|
||||
} // namespace dingosdk::mods
|
||||
@@ -0,0 +1,401 @@
|
||||
#include "mod_store_copies.h"
|
||||
|
||||
#include "mod_catalog.h"
|
||||
#include "mod_merge_internal.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::mods {
|
||||
using namespace detail;
|
||||
namespace {
|
||||
// Where the game keeps its items, the store's among them, and how it names them:
|
||||
// most under items/, the rest (player-card art, skin tones) after their key,
|
||||
// and every key the store sells starts with own_. A cheap way to leave the
|
||||
// textures and presets that fill the rest of the bundle unread.
|
||||
constexpr std::string_view items_toc = "Win32/items.toc";
|
||||
bool item_name(std::string_view name) {
|
||||
const auto slash = name.rfind('/');
|
||||
return name.starts_with("items/") ||
|
||||
name.substr(slash == std::string_view::npos ? 0 : slash + 1).starts_with("own_");
|
||||
}
|
||||
|
||||
// An item's AssetPaths say what kind of asset each one is: 1 a morph preset and
|
||||
// 2 a base mesh, which are what the item is fitted on; every other kind (3, an
|
||||
// appearance preset, for nearly all of them) is what it looks like. Of the
|
||||
// game's own items, store and free ones share shapes and never a look.
|
||||
constexpr bool shape_kind(std::int64_t kind) noexcept { return kind == 1 || kind == 2; }
|
||||
|
||||
std::string hex(const fb::Sha1& sha1) {
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string text;
|
||||
for (const auto value : sha1.bytes) {
|
||||
const auto byte = static_cast<unsigned>(value);
|
||||
text += digits[byte >> 4];
|
||||
text += digits[byte & 15];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const std::string* text_of(const fb::ebx::Object& object, std::string_view name) {
|
||||
const auto* field = object.find(name);
|
||||
return field ? std::get_if<std::string>(&field->value.data) : nullptr;
|
||||
}
|
||||
std::optional<std::int64_t> integer_of(const fb::ebx::Object& object, std::string_view name) {
|
||||
const auto* field = object.find(name);
|
||||
if (!field) return std::nullopt;
|
||||
if (const auto* value = std::get_if<std::int64_t>(&field->value.data)) return *value;
|
||||
if (const auto* value = std::get_if<std::uint64_t>(&field->value.data)) return static_cast<std::int64_t>(*value);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Walks an item's data: every field goes into `text`, in order and with what
|
||||
// its pointers lead to, and the fields that say what the item is go into its
|
||||
// looks and shapes.
|
||||
class ItemReader final {
|
||||
public:
|
||||
ItemReader(const fb::ebx::Document& document, ItemContent& item, std::string& text)
|
||||
: document_(document), item_(item), text_(text) {}
|
||||
|
||||
void value(const fb::ebx::Value& value, int depth = 0) {
|
||||
// Deeper than any item's data goes; the walk has to end on any document.
|
||||
if (depth > 32) { text_ += '?'; return; }
|
||||
struct Visit {
|
||||
ItemReader& reader;
|
||||
int depth;
|
||||
void operator()(std::monostate) const { reader.text_ += '~'; }
|
||||
void operator()(bool held) const { reader.text_ += held ? "true" : "false"; }
|
||||
void operator()(std::int64_t held) const { reader.text_ += std::to_string(held); }
|
||||
void operator()(std::uint64_t held) const { reader.text_ += std::to_string(held); }
|
||||
void operator()(double held) const { reader.text_ += std::to_string(held); }
|
||||
void operator()(const std::string& held) const { reader.text_ += '"' + lower(held) + '"'; }
|
||||
void operator()(const fb::Guid& held) const { reader.text_ += held.string(); }
|
||||
void operator()(const fb::Sha1& held) const { reader.text_ += hex(held); }
|
||||
void operator()(const fb::ebx::ResourceReference& held) const { reader.text_ += "resource " + std::to_string(held.id); }
|
||||
void operator()(const fb::ebx::PointerReference& held) const { reader.pointer(held, depth); }
|
||||
void operator()(const fb::ebx::TypeReference& held) const { reader.text_ += "type " + std::to_string(held.encoded); }
|
||||
void operator()(const fb::ebx::BoxedReference& held) const { reader.text_ += "boxed " + std::to_string(held.encodedType); }
|
||||
void operator()(const std::shared_ptr<fb::ebx::Object>& held) const {
|
||||
if (held) reader.object(*held, depth);
|
||||
else reader.text_ += "null";
|
||||
}
|
||||
void operator()(const fb::ebx::Value::Array& held) const {
|
||||
reader.text_ += '[';
|
||||
for (const auto& entry : held) { reader.value(entry, depth + 1); reader.text_ += ','; }
|
||||
reader.text_ += ']';
|
||||
}
|
||||
};
|
||||
std::visit(Visit{*this, depth}, value.data);
|
||||
}
|
||||
// Whether the data has a field at all; the player-card items have none.
|
||||
[[nodiscard]] bool any() const noexcept { return fields_ != 0; }
|
||||
|
||||
private:
|
||||
[[nodiscard]] const fb::ebx::ImportReference* import(const fb::ebx::PointerReference& reference) const {
|
||||
if (reference.kind != fb::ebx::PointerKind::external || reference.index < 0 ||
|
||||
static_cast<std::size_t>(reference.index) >= document_.imports.size())
|
||||
return nullptr;
|
||||
return &document_.imports[static_cast<std::size_t>(reference.index)];
|
||||
}
|
||||
static std::string named(const fb::ebx::ImportReference& reference) {
|
||||
return reference.fileGuid.string() + '/' + reference.classGuid.string();
|
||||
}
|
||||
|
||||
void pointer(const fb::ebx::PointerReference& reference, int depth) {
|
||||
if (reference.kind == fb::ebx::PointerKind::null) { text_ += "null"; return; }
|
||||
if (const auto* other = import(reference)) { text_ += "import " + named(*other); return; }
|
||||
const auto index = static_cast<std::size_t>(reference.index);
|
||||
if (reference.kind != fb::ebx::PointerKind::internal || reference.index < 0 ||
|
||||
index >= document_.instances.size() || !document_.instances[index].object) {
|
||||
text_ += "nothing";
|
||||
return;
|
||||
}
|
||||
// An instance that leads back to itself is written once.
|
||||
if (std::find(open_.begin(), open_.end(), index) != open_.end()) { text_ += "again"; return; }
|
||||
open_.push_back(index);
|
||||
object(*document_.instances[index].object, depth);
|
||||
open_.pop_back();
|
||||
}
|
||||
|
||||
void object(const fb::ebx::Object& object, int depth) {
|
||||
parts(object);
|
||||
if (object.descriptor >= 0 && static_cast<std::size_t>(object.descriptor) < document_.types.size())
|
||||
text_ += document_.types[static_cast<std::size_t>(object.descriptor)].name;
|
||||
text_ += '{';
|
||||
for (const auto& field : object.fields) {
|
||||
++fields_;
|
||||
text_ += field.name;
|
||||
text_ += '=';
|
||||
value(field.value, depth + 1);
|
||||
text_ += ';';
|
||||
}
|
||||
text_ += '}';
|
||||
}
|
||||
|
||||
void parts(const fb::ebx::Object& object) {
|
||||
// Clothing, decks, wheels, trucks: the assets the item is made of, by name.
|
||||
if (const auto* name = text_of(object, "AssetName"); name && !name->empty())
|
||||
if (const auto kind = integer_of(object, "AssetTypeId"))
|
||||
(shape_kind(*kind) ? item_.shapes : item_.looks)
|
||||
.push_back("asset " + std::to_string(*kind) + ' ' + lower(*name));
|
||||
// Stickers and tattoos: the textures of the decal.
|
||||
if (const auto* name = text_of(object, "TextureName"); name && !name->empty())
|
||||
item_.looks.push_back("texture " + lower(*name));
|
||||
// Quick Drop objects: what is spawned, and the bundle it is in.
|
||||
if (const auto* name = text_of(object, "BuildKitPrefabName"); name && !name->empty())
|
||||
item_.looks.push_back("prefab " + lower(*name));
|
||||
if (const auto* name = text_of(object, "BuildKitBundleName"); name && !name->empty())
|
||||
item_.looks.push_back("bundle " + lower(*name));
|
||||
// Player-card backgrounds and icons: the picture, which is another asset.
|
||||
if (const auto* texture = object.find("ItemTexture"))
|
||||
if (const auto* reference = std::get_if<fb::ebx::PointerReference>(&texture->value.data))
|
||||
if (const auto* other = import(*reference)) item_.looks.push_back("picture " + named(*other));
|
||||
// Emotes: a value of the game's gesture state, which is the animation played.
|
||||
if (const auto* state = object.find("GameState"))
|
||||
if (const auto gesture = integer_of(object, "Value"))
|
||||
if (const auto* held = std::get_if<std::shared_ptr<fb::ebx::Object>>(&state->value.data); held && *held)
|
||||
if (const auto* asset = (*held)->find("AssetRef"))
|
||||
if (const auto* reference = std::get_if<fb::ebx::PointerReference>(&asset->value.data))
|
||||
if (const auto* other = import(*reference))
|
||||
item_.looks.push_back("gesture " + named(*other) + ' ' + std::to_string(*gesture));
|
||||
}
|
||||
|
||||
const fb::ebx::Document& document_;
|
||||
ItemContent& item_;
|
||||
std::string& text_;
|
||||
std::vector<std::size_t> open_;
|
||||
std::size_t fields_{};
|
||||
};
|
||||
|
||||
class Scan final {
|
||||
public:
|
||||
Scan(const Catalog& catalog, std::vector<std::string>* notes)
|
||||
: baseRoot_(catalog.data_root / L"Data"), gameRoot_(catalog.data_root),
|
||||
layout_(vfs::read_layout(baseRoot_ / L"layout.toc")),
|
||||
store_(baseRoot_, catalog.root / generated_folder, layout_.root), notes_(notes) {}
|
||||
|
||||
// The items a mod adds to the game's bundles, and the game's own it changes.
|
||||
// An item only loads from a bundle the game loads, and the lists the game
|
||||
// reads its items from are in one of those, so a mod's own bundles (a map's
|
||||
// levels) are not looked at.
|
||||
[[nodiscard]] std::vector<ItemContent> read_mod(const Mod& mod) {
|
||||
std::vector<ItemContent> items;
|
||||
for (const auto& relative : scan(mod.directory).tocs) {
|
||||
const auto* game = game_toc(relative);
|
||||
if (!game) continue;
|
||||
fb::TocDocument own;
|
||||
try {
|
||||
own = fb::read_toc(read_file(mod.directory / fs::path(relative)));
|
||||
} catch (const std::exception& failure) {
|
||||
note(mod.name + ": " + relative + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
|
||||
continue;
|
||||
}
|
||||
for (const auto& bundle : own.bundles) {
|
||||
const auto shipped = game->find(lower(bundle.name));
|
||||
if (shipped == game->end()) continue;
|
||||
try {
|
||||
// A bundle the mod only passes through reads every file from the game.
|
||||
const auto region = fb::read_bundle_region(bundle.region);
|
||||
if (std::none_of(region.files.begin(), region.files.end(),
|
||||
[](const fb::BundleFileInfo& file) { return file.location.patch; }))
|
||||
continue;
|
||||
const auto listing = list_bundle(store_, mod.directory, baseRoot_, bundle, gameRoot_);
|
||||
const auto* known = listing ? game_assets(relative, *shipped->second) : nullptr;
|
||||
if (!known) continue;
|
||||
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index) {
|
||||
const auto& asset = listing->manifest.ebx[index];
|
||||
const auto at = listing->first + index;
|
||||
// The mod's own payloads are in its own archives.
|
||||
if (at >= listing->files.size() || !listing->files[at].location.patch) continue;
|
||||
const auto original = known->find(lower(asset.name));
|
||||
if (original != known->end() && original->second == asset.sha1) continue;
|
||||
try {
|
||||
const auto document = fb::ebx::read_document(
|
||||
read_asset(store_, mod.directory, baseRoot_, *listing, index, gameRoot_));
|
||||
if (auto item = item_content(document)) items.push_back(std::move(*item));
|
||||
} catch (const std::exception&) {
|
||||
// Not an asset this reads; the game may not read it either.
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& failure) {
|
||||
note(mod.name + ": " + bundle.name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Every item the game ships; how many were read.
|
||||
[[nodiscard]] std::size_t read_game(StoreItems& items, const StoreItem& sold) {
|
||||
const auto* toc = game_toc(items_toc);
|
||||
if (!toc) return 0;
|
||||
std::size_t read{};
|
||||
for (const auto& [name, bundle] : *toc) {
|
||||
try {
|
||||
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_);
|
||||
if (!listing) continue;
|
||||
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index) {
|
||||
if (!item_name(lower(listing->manifest.ebx[index].name))) continue;
|
||||
try {
|
||||
const auto document = fb::ebx::read_document(
|
||||
read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
|
||||
const auto item = item_content(document);
|
||||
if (!item) continue;
|
||||
items.add(*item, sold(lower(item->key)));
|
||||
++read;
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
} catch (const std::exception& failure) {
|
||||
note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
|
||||
}
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
private:
|
||||
using Bundles = std::map<std::string, const fb::TocBundle*, std::less<>>;
|
||||
using Assets = std::map<std::string, fb::Sha1, std::less<>>;
|
||||
|
||||
const Bundles* game_toc(std::string_view relative) {
|
||||
const auto key = lower(relative);
|
||||
if (const auto found = tocs_.find(key); found != tocs_.end())
|
||||
return found->second.index.empty() ? nullptr : &found->second.index;
|
||||
auto& entry = tocs_[key];
|
||||
std::error_code error;
|
||||
const auto path = baseRoot_ / fs::path(std::string(relative));
|
||||
if (!fs::is_regular_file(path, error)) return nullptr;
|
||||
try {
|
||||
entry.document = fb::read_toc(read_file(path));
|
||||
} catch (const std::exception& failure) {
|
||||
note("The game's " + std::string(relative) + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
|
||||
return nullptr;
|
||||
}
|
||||
for (const auto& bundle : entry.document.bundles) entry.index.emplace(lower(bundle.name), &bundle);
|
||||
return entry.index.empty() ? nullptr : &entry.index;
|
||||
}
|
||||
|
||||
// The game's copy of a bundle: each EBX it holds, to tell a mod's additions
|
||||
// and changes from what it carries untouched. Null when it cannot be read.
|
||||
const Assets* game_assets(std::string_view relative, const fb::TocBundle& bundle) {
|
||||
const auto key = lower(relative) + '|' + lower(bundle.name);
|
||||
if (const auto found = assets_.find(key); found != assets_.end())
|
||||
return found->second ? &*found->second : nullptr;
|
||||
auto& entry = assets_[key];
|
||||
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, bundle, gameRoot_);
|
||||
if (!listing) return nullptr;
|
||||
entry.emplace();
|
||||
for (const auto& asset : listing->manifest.ebx) entry->emplace(lower(asset.name), asset.sha1);
|
||||
return &*entry;
|
||||
}
|
||||
|
||||
void note(std::string text) {
|
||||
if (notes_) notes_->push_back(std::move(text));
|
||||
}
|
||||
|
||||
struct GameToc {
|
||||
fb::TocDocument document;
|
||||
Bundles index;
|
||||
};
|
||||
fs::path baseRoot_, gameRoot_;
|
||||
vfs::Layout layout_;
|
||||
CasStore store_;
|
||||
std::vector<std::string>* notes_;
|
||||
std::map<std::string, GameToc, std::less<>> tocs_;
|
||||
std::map<std::string, std::optional<Assets>, std::less<>> assets_;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
std::optional<ItemContent> item_content(const fb::ebx::Document& document) {
|
||||
const auto* root = document.root();
|
||||
if (!root || !root->object) return std::nullopt;
|
||||
const auto* key = text_of(*root->object, "Key");
|
||||
const auto* data = root->object->find("ItemData");
|
||||
if (!key || key->empty() || !data) return std::nullopt;
|
||||
ItemContent item;
|
||||
item.key = *key;
|
||||
std::string text;
|
||||
ItemReader reader(document, item, text);
|
||||
reader.value(data->value);
|
||||
for (auto* parts : {&item.looks, &item.shapes}) {
|
||||
std::sort(parts->begin(), parts->end());
|
||||
parts->erase(std::unique(parts->begin(), parts->end()), parts->end());
|
||||
}
|
||||
// Data with no field in it is every such item's alike, and says nothing.
|
||||
if (reader.any()) item.data = hex(sha1_of(std::as_bytes(std::span(text))));
|
||||
return item;
|
||||
}
|
||||
|
||||
const std::string* StoreItems::Parts::owner(std::string_view part) const {
|
||||
if (free.contains(part)) return nullptr;
|
||||
const auto found = sold.find(part);
|
||||
return found == sold.end() ? nullptr : &found->second;
|
||||
}
|
||||
|
||||
void StoreItems::add(const ItemContent& item, bool sold) {
|
||||
const auto file = [&](Parts& parts, const std::string& part) {
|
||||
if (sold) parts.sold.try_emplace(part, item.key);
|
||||
else parts.free.insert(part);
|
||||
};
|
||||
for (const auto& look : item.looks) file(looks_, look);
|
||||
// A store item with a look is told by that; its shape is anyone's to use.
|
||||
if (!sold || item.looks.empty())
|
||||
for (const auto& shape : item.shapes) file(shapes_, shape);
|
||||
if (!item.data.empty()) file(data_, item.data);
|
||||
}
|
||||
|
||||
std::string StoreItems::original(const ItemContent& item) const {
|
||||
for (const auto& look : item.looks)
|
||||
if (const auto* key = looks_.owner(look)) return *key;
|
||||
if (item.looks.empty())
|
||||
for (const auto& shape : item.shapes)
|
||||
if (const auto* key = shapes_.owner(shape)) return *key;
|
||||
if (!item.data.empty())
|
||||
if (const auto* key = data_.owner(item.data)) return *key;
|
||||
return {};
|
||||
}
|
||||
|
||||
StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector<std::string>* notes) noexcept {
|
||||
StoreCopies result;
|
||||
try {
|
||||
if (!sold || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; }))
|
||||
return result;
|
||||
Scan scan(catalog, notes);
|
||||
std::vector<std::pair<const Mod*, std::vector<ItemContent>>> added;
|
||||
for (const auto& mod : catalog.mods) {
|
||||
if (!mod.provides_layout) continue;
|
||||
if (auto items = scan.read_mod(mod); !items.empty()) added.emplace_back(&mod, std::move(items));
|
||||
}
|
||||
if (added.empty()) return result;
|
||||
StoreItems game;
|
||||
result.game_items = scan.read_game(game, sold);
|
||||
if (!result.game_items) {
|
||||
if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run");
|
||||
return result;
|
||||
}
|
||||
for (const auto& [mod, items] : added) {
|
||||
StoreCopies::Source source{mod->name};
|
||||
for (const auto& item : items) {
|
||||
const auto key = lower(item.key);
|
||||
// A store item under its own key is held by that key already.
|
||||
if (sold(key)) continue;
|
||||
const auto original = game.original(item);
|
||||
if (original.empty()) continue;
|
||||
if (!source.count++) {
|
||||
source.example = item.key;
|
||||
source.original = original;
|
||||
}
|
||||
result.items.insert_or_assign(key, lower(original));
|
||||
}
|
||||
if (source.count) result.mods.push_back(std::move(source));
|
||||
}
|
||||
} catch (const std::exception& failure) {
|
||||
if (notes) notes->push_back(std::string(store_copies_check) + " could not run: " + failure.what());
|
||||
result = {};
|
||||
} catch (...) {
|
||||
result = {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dingosdk::mods
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::frostbite::ebx {
|
||||
struct Document;
|
||||
}
|
||||
|
||||
namespace dingosdk::mods {
|
||||
|
||||
struct Catalog;
|
||||
|
||||
// ReSkate hands out every cosmetic except the ones the game's store sells, and
|
||||
// tells those apart by key (content_cache::Catalogs::reserved). A mod that adds
|
||||
// a store item again under a key of its own gets round that: the copy is in no
|
||||
// catalogue, so it is handed out like any other mod's item. What an item is
|
||||
// does not change with its key, though. It still names the store item's
|
||||
// appearance preset, decal textures, object or gesture, and those are what this
|
||||
// check holds a mod's items against. A mod that adds such a copy is not loaded
|
||||
// at all: the merge leaves it out whole (mod_merge.cpp), as it does a mod that
|
||||
// cannot be merged.
|
||||
|
||||
// All such a mod is told, in the launcher, the game and the log (which the
|
||||
// mod's author reads too): that it could not be merged, and this. Nothing of
|
||||
// what was looked for or found. The number is how ReSkate's own people tell it
|
||||
// from a mod that really is damaged; the log names the check by it as well,
|
||||
// when something could not be read for it.
|
||||
inline constexpr std::string_view store_copies_problem = "merge error 0x5343";
|
||||
inline constexpr std::string_view store_copies_check = "merge check 0x5343";
|
||||
|
||||
// What an item is made of, whatever it is called. Lower-case; the lists sorted.
|
||||
struct ItemContent {
|
||||
std::string key; // the item's Key, as written
|
||||
// What it looks like or does: appearance presets, decal textures, a
|
||||
// player card's picture, an object's prefab and bundle, a gesture.
|
||||
std::vector<std::string> looks;
|
||||
// The meshes and morph presets it is fitted on. Mods fit looks of their own
|
||||
// on the game's shapes, a store item's included, so a shape alone says
|
||||
// nothing.
|
||||
std::vector<std::string> shapes;
|
||||
std::string data; // a digest of every field of its item data, to compare whole
|
||||
};
|
||||
// Empty when the document is not an item (an asset with a Key and ItemData).
|
||||
[[nodiscard]] std::optional<ItemContent> item_content(const frostbite::ebx::Document& document);
|
||||
|
||||
// The game's own items, which a mod's are held against.
|
||||
class StoreItems final {
|
||||
public:
|
||||
// `sold`: the store sells it, so ReSkate never hands it out.
|
||||
void add(const ItemContent& item, bool sold);
|
||||
// The key of the store item `item` is a copy of, or empty. It is one when
|
||||
// it has a look only a store item has, when it has no look of its own and
|
||||
// is fitted on a shape only a store item with no look has (a whole
|
||||
// costume), or when its item data is a store item's field for field.
|
||||
// Anything an item the store does not sell has as well is nobody's.
|
||||
[[nodiscard]] std::string original(const ItemContent& item) const;
|
||||
|
||||
private:
|
||||
struct Parts {
|
||||
std::map<std::string, std::string, std::less<>> sold; // part -> the store item's key
|
||||
std::set<std::string, std::less<>> free; // parts of the other items
|
||||
[[nodiscard]] const std::string* owner(std::string_view part) const;
|
||||
};
|
||||
Parts looks_, shapes_, data_;
|
||||
};
|
||||
|
||||
struct StoreCopies {
|
||||
// Lower-case key of each copy -> the lower-case key of the store item it copies.
|
||||
std::map<std::string, std::string, std::less<>> items;
|
||||
// The mods that add them, highest priority first.
|
||||
struct Source {
|
||||
std::string mod;
|
||||
std::size_t count{};
|
||||
std::string example, original; // one of its copies and what that copies, as written
|
||||
};
|
||||
std::vector<Source> mods;
|
||||
// How many of the game's own items the mods' were held against; none when no mod adds an item.
|
||||
std::size_t game_items{};
|
||||
};
|
||||
|
||||
// Whether the store sells the item with this lower-case key.
|
||||
using StoreItem = std::function<bool(const std::string&)>;
|
||||
|
||||
// Reads the items every enabled mod adds to (or changes in) the game's bundles
|
||||
// and holds them against the game's own. The game's are only read when a mod
|
||||
// has an item at all. Never throws: a bundle that cannot be read is left out
|
||||
// (the merge leaves its mod out of the game too) and noted in `notes`.
|
||||
[[nodiscard]] StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold,
|
||||
std::vector<std::string>* notes = nullptr) noexcept;
|
||||
|
||||
} // namespace dingosdk::mods
|
||||
@@ -12,7 +12,7 @@ public:
|
||||
: input_(input), context_(context), options_(options) {}
|
||||
|
||||
Node read(unsigned depth = 0) {
|
||||
if (depth > 32 || ++nodes_ > 32768) fail("DB nesting/entry limit exceeded");
|
||||
if (depth > 32 || ++nodes_ > options_.max_entries) fail("DB nesting/entry limit exceeded");
|
||||
const auto tag = take(1)[0];
|
||||
Node node;
|
||||
node.type = tag & 31;
|
||||
|
||||
@@ -41,6 +41,10 @@ struct Options {
|
||||
// an entry. layout.toc's meta section repeats keys by design, so readers of
|
||||
// that file turn the rule off.
|
||||
bool unique_fields = true;
|
||||
// How many values a file may hold: more than any the game reads whole at
|
||||
// startup. A reader of something longer by nature (the chunk list of a
|
||||
// level's root bundle) raises it to what its input can hold.
|
||||
std::size_t max_entries = 32768;
|
||||
};
|
||||
|
||||
// Throws std::runtime_error prefixed with `context`, for example "InitFS".
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/World/custom_level_manifest.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include "Engine/Game/Build/20260929/live_mods.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
@@ -211,7 +212,7 @@ bool refresh_toc(const std::string &superbundle, const std::filesystem::path &fi
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
std::vector<char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
if (bytes.size() <= native::toc_header + 4) {
|
||||
problem = file.filename().string() + " could not be read";
|
||||
problem = path_utf8(file.filename()) + " could not be read";
|
||||
return false;
|
||||
}
|
||||
const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "Engine/Vfs/mod_scoring.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/mod_layers.h"
|
||||
#include "Extension/UI/Startup/startup_window.h"
|
||||
@@ -268,7 +269,7 @@ bool start_mod_layers(std::uintptr_t base, std::string& error) {
|
||||
}
|
||||
if (!catalog.present) {
|
||||
logging::log(logging::Level::info, logging::Channel::assets,
|
||||
"No Mods folder at {}; only Patch and Data are layered.", catalog.root.string());
|
||||
"No Mods folder at {}; only Patch and Data are layered.", path_utf8(catalog.root));
|
||||
return true;
|
||||
}
|
||||
if (!catalog.merged) {
|
||||
@@ -322,7 +323,7 @@ bool start_mod_layers(std::uintptr_t base, std::string& error) {
|
||||
}
|
||||
installed_base = base;
|
||||
logging::log(logging::Level::info, logging::Channel::assets,
|
||||
"Merged mod patch ready at {}", state().merged_root.string());
|
||||
"Merged mod patch ready at {}", path_utf8(state().merged_root));
|
||||
return true;
|
||||
}
|
||||
LayoutObjects layout_objects() noexcept { return {seen_manager.load(), seen_layers.load()}; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "commands.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include <memory>
|
||||
|
||||
namespace dingosdk::console {
|
||||
@@ -47,7 +48,7 @@ void register_console_commands(Commands ®istry) {
|
||||
"and Tab to complete names and "
|
||||
"arguments.");
|
||||
out("Groups: Movement, Gameplay, World, Graphics, Progression, Objects, "
|
||||
"Engine, Developer, Console.");
|
||||
"Engine, Console.");
|
||||
return;
|
||||
}
|
||||
const auto &name = std::get<std::string>(args[0]);
|
||||
@@ -179,7 +180,7 @@ void register_console_commands(Commands ®istry) {
|
||||
log.run = [](const Model &, const Values &, const Output &out) {
|
||||
const auto s = logging::status();
|
||||
out(std::string("Logging level: ") + std::string(logging::name(s.level)));
|
||||
out((s.directory / L"ReSkate.log").string());
|
||||
out(path_utf8(s.directory / L"ReSkate.log"));
|
||||
};
|
||||
registry.add(std::move(log));
|
||||
auto level = argument("level");
|
||||
@@ -222,6 +223,7 @@ const Commands &game_commands() {
|
||||
register_park_editor_commands(*result);
|
||||
register_multiplayer_commands(*result);
|
||||
register_perf_commands(*result);
|
||||
register_trainer_commands(*result);
|
||||
return result;
|
||||
}();
|
||||
return *registry;
|
||||
|
||||
@@ -22,6 +22,7 @@ void register_object_commands(Commands &);
|
||||
void register_park_editor_commands(Commands &);
|
||||
void register_multiplayer_commands(Commands &);
|
||||
void register_perf_commands(Commands &);
|
||||
void register_trainer_commands(Commands &);
|
||||
// Runtime adapters. Invoked only by the verified game-thread dispatcher.
|
||||
void request_debug(overlay::DebugAction, bool enabled = false, float value = 0);
|
||||
void request_feature(overlay::OfflineFeatureGroup, bool enabled);
|
||||
|
||||
@@ -101,7 +101,7 @@ void register_perf_commands(Commands& registry) {
|
||||
seconds.minimum = 0.5;
|
||||
seconds.maximum = 60;
|
||||
auto target = argument("client|present|all|<thread id>", Type::text, true);
|
||||
target.choices = {"client", "present", "all"};
|
||||
target.complete = [](const Model&, auto) { return std::vector<std::string>{"client", "present", "all"}; };
|
||||
auto rate = argument("rate", Type::unsigned_integer, true);
|
||||
rate.minimum = 50;
|
||||
rate.maximum = 4000;
|
||||
|
||||
@@ -20,12 +20,12 @@ void report_board(std::uintptr_t entity, std::uint64_t steam_id, std::string_vie
|
||||
}
|
||||
if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return;
|
||||
*entry = {entity, now, hash};
|
||||
logging::log(level, logging::Channel::customization, "Developer board (Steam {}, actor {:#x}): {}", steam_id, entity, message);
|
||||
logging::log(level, logging::Channel::customization, "Board items (Steam {}, actor {:#x}): {}", steam_id, entity, message);
|
||||
}
|
||||
bool write_color(std::uintptr_t base, const developer_board_detail::Binding &binding, const developer_board_detail::Color &color) noexcept {
|
||||
__try {
|
||||
if (!binding.node) {
|
||||
const developer_board_detail::NativeColor value{color, 0, binding.type};
|
||||
const developer_board_detail::NativeColor value{color, 0, base + addr::native_cosmetics::color_rgb_type};
|
||||
// Let the engine allocate/copy/hash a missing typed parameter. Priority
|
||||
// zero allows later native appearance presets to replace this override.
|
||||
return reinterpret_cast<std::uint64_t (*)(std::uintptr_t, const developer_board_detail::ParameterKey *,
|
||||
@@ -43,12 +43,26 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept {
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
// What each of the board's cosmetics does for this player: deck, grip tape, trucks, wheels.
|
||||
using Animations = std::array<developer_board_detail::ItemAnimation, developer_board_detail::part_count>;
|
||||
Animations board_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept {
|
||||
const auto mark = multiplayer::identity_mark(steam_id);
|
||||
Animations out;
|
||||
for (std::size_t i = 0; i < out.size(); ++i)
|
||||
out[i] = developer_board_detail::item_animation(mark, styles[multiplayer::skater_mark_items + i]);
|
||||
return out;
|
||||
}
|
||||
bool any_on(const Animations &animations) noexcept {
|
||||
return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; });
|
||||
}
|
||||
} // namespace
|
||||
void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperBoardState &state) noexcept {
|
||||
const bool developer = multiplayer::reskate_developer(steam_id);
|
||||
std::uint64_t generation, DeveloperBoardState &state,
|
||||
const multiplayer::MarkStyles &styles) noexcept {
|
||||
const auto animations = board_animations(steam_id, styles);
|
||||
const bool listed = any_on(animations);
|
||||
if (!board) { state = {}; return; }
|
||||
if (!developer && !state.count) return;
|
||||
if (!listed && state.colors.empty()) return;
|
||||
try {
|
||||
auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); };
|
||||
std::array<unsigned char, addr::native_cosmetics::publish_materials_prefix.size()> publisher{};
|
||||
@@ -64,14 +78,16 @@ void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint
|
||||
bool published = true;
|
||||
auto write = [base](const auto &binding, const auto &color) { return write_color(base, binding, color); };
|
||||
auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; };
|
||||
developer_board_detail::animate(state, live, board, generation, developer, GetTickCount64(), write, publish);
|
||||
const auto wanted = static_cast<std::size_t>(std::count_if(live.bindings.begin(), live.bindings.begin() + live.count,
|
||||
[](const auto &binding) { return binding.eligible; }));
|
||||
developer_board_detail::animate(state, live, board, generation, animations, GetTickCount64(), write, publish);
|
||||
// The colours of the parts that are on: found, and taken.
|
||||
const auto wanted = std::count_if(live.bindings.begin(), live.bindings.end(),
|
||||
[&](const auto &binding) { return animations[binding.part].on; });
|
||||
const auto taken = std::count_if(state.colors.begin(), state.colors.end(),
|
||||
[&](const auto &saved) { return animations[saved.binding.part].on; });
|
||||
if (!published) report_board(board, steam_id, "native material publication failed", logging::Level::warning);
|
||||
else if (developer && !live.pending && state.count < wanted)
|
||||
else if (listed && !live.pending && taken < wanted)
|
||||
report_board(board, steam_id, "waiting for the remaining board color overrides", logging::Level::warning);
|
||||
else if (developer && state.count)
|
||||
report_board(board, steam_id, "RGB active on the owned pink board cosmetics", logging::Level::info);
|
||||
else if (listed && taken) report_board(board, steam_id, "animation active on the board", logging::Level::info);
|
||||
} catch (const std::exception &error) {
|
||||
report_board(board, steam_id, error.what(), logging::Level::warning);
|
||||
} catch (...) {}
|
||||
@@ -81,15 +97,16 @@ void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool
|
||||
if (!ready) return;
|
||||
try {
|
||||
const auto social = multiplayer::steam_social_snapshot();
|
||||
const auto id = social ? social->local.id : 0;
|
||||
if (!multiplayer::reskate_developer(id) && !state.count) return;
|
||||
const auto id = social && multiplayer::own_items_shown() ? social->local.id : 0;
|
||||
const auto styles = developer_hoodie_detail::own_styles.load();
|
||||
if (!any_on(board_animations(id, styles)) && state.colors.empty()) return;
|
||||
const auto local = multiplayer::capture_local(base, client, false);
|
||||
if (!local.ready) return;
|
||||
auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); };
|
||||
// Cosmetic changes can replace the board while retaining the skater.
|
||||
// Re-resolve the actual local association, rather than capture's cached board.
|
||||
const auto board = multiplayer::read_native_board(read, base, local.entity);
|
||||
update_developer_board(base, board.entity, id, local.context, state);
|
||||
update_developer_board(base, board.entity, id, local.context, state, styles);
|
||||
} catch (...) {}
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -2,7 +2,10 @@
|
||||
#include "developer_board_material.h"
|
||||
|
||||
namespace dingosdk {
|
||||
// Colours the board a listed player rides: `styles` is how they have each marked cosmetic, the
|
||||
// board's deck, grip tape, trucks and wheels after the skater's.
|
||||
void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperBoardState &state) noexcept;
|
||||
std::uint64_t generation, DeveloperBoardState &state,
|
||||
const multiplayer::MarkStyles &styles) noexcept;
|
||||
void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept;
|
||||
} // namespace dingosdk
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
namespace dingosdk {
|
||||
namespace developer_board_detail {
|
||||
using developer_hoodie_detail::Binding;
|
||||
using developer_hoodie_detail::Color;
|
||||
using developer_hoodie_detail::ItemAnimation;
|
||||
using developer_hoodie_detail::ParameterKey;
|
||||
using developer_hoodie_detail::animate;
|
||||
using developer_hoodie_detail::item_animation;
|
||||
using developer_hoodie_detail::item_color;
|
||||
using developer_hoodie_detail::material_hash;
|
||||
using developer_hoodie_detail::rainbow;
|
||||
using developer_hoodie_detail::settle_publishes;
|
||||
inline constexpr std::uint64_t graphic_color = 0x873f48af36e50d59ULL;
|
||||
inline constexpr std::uint64_t base_color = 0x8c46cd354b18a9e2ULL;
|
||||
inline constexpr Color shader_default{.5f, .5f, .5f};
|
||||
@@ -14,58 +20,40 @@ inline constexpr std::array<std::string_view, 3> slots{
|
||||
"Characters/Customization/Board/Slots/Skateboard_Deck_Slot",
|
||||
"Characters/Customization/Board/Slots/Skateboard_Trucks_Slot",
|
||||
"Characters/Customization/Board/Slots/Skateboard_Wheels_Slot"};
|
||||
// One colour of the board: a named material of the controller assigned one of `slots`, the
|
||||
// shader key, and which of the board's marked cosmetics it colours (deck, grip tape, trucks,
|
||||
// wheels: the order of their styles after the skater's, multiplayer::mark_item_names).
|
||||
struct Target {
|
||||
std::string_view asset, material;
|
||||
std::uint32_t recipe_slot;
|
||||
std::string_view material;
|
||||
std::size_t controller;
|
||||
std::uint64_t key;
|
||||
std::uint8_t part;
|
||||
};
|
||||
inline constexpr std::size_t part_count = multiplayer::mark_items - multiplayer::skater_mark_items;
|
||||
// The board loader registers Deck_Mat / DeckTop_Mat for the compiled sections
|
||||
// Skateboard_base_mat / mat_skateboard_top. Use the live named map identities.
|
||||
// Recipe identities, assigned board controllers and serialized shader keys from
|
||||
// the four pink cosmetics. The deck's wood follows its Solid Pink graphic.
|
||||
// Skateboard_base_mat / mat_skateboard_top, whatever deck is on it: use the live named map
|
||||
// identities. The deck's wood follows its graphic.
|
||||
inline constexpr std::array<Target, 5> targets{{
|
||||
{"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, graphic_color},
|
||||
{"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, base_color},
|
||||
{"Own_DeckGripColor_Gen_Popsicle_00006", "DeckTop_Mat", 0x755d5ee0, 0, graphic_color},
|
||||
{"Own_Truck_Gen_Default_00002", "Truck_Mat", 0x0add98be, 1, base_color},
|
||||
{"Own_WheelColor_Gen_Classic_00005", "Wheel_mat", 0xc83bb94e, 2, base_color},
|
||||
{"Deck_Mat", 0, graphic_color, 0},
|
||||
{"Deck_Mat", 0, base_color, 0},
|
||||
{"DeckTop_Mat", 0, graphic_color, 1},
|
||||
{"Truck_Mat", 1, base_color, 2},
|
||||
{"Wheel_mat", 2, base_color, 3},
|
||||
}};
|
||||
// Native ParamDbKey and inline NativeValue layout used by addEsVector's setter.
|
||||
// The key's first word is the final two serialized GUID bytes. Its type index
|
||||
// comes from the live ColorRgb descriptor, not a serialized Vec3 or boxed value.
|
||||
struct ParameterKey {
|
||||
std::uint16_t identity{}, size{}, type{}, count{};
|
||||
std::uint64_t hash{};
|
||||
};
|
||||
// The inline NativeValue layout used by addEsVector's setter. Its type is the live ColorRgb
|
||||
// descriptor, not a serialized Vec3 or boxed value.
|
||||
struct NativeColor {
|
||||
Color color{};
|
||||
std::uint32_t padding{};
|
||||
std::uintptr_t type{};
|
||||
std::uint32_t flags{3}, reserved{};
|
||||
};
|
||||
static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8);
|
||||
static_assert(sizeof(NativeColor) == 32 && offsetof(NativeColor, type) == 16 && offsetof(NativeColor, flags) == 24);
|
||||
inline ParameterKey parameter_key(std::uint64_t hash, std::uint16_t type) noexcept {
|
||||
return {static_cast<std::uint16_t>(hash == graphic_color ? 0xd442 : 0x8c54), sizeof(Color), type, 1, hash};
|
||||
}
|
||||
struct Binding {
|
||||
std::uintptr_t item{}, material{}, node{}, type{};
|
||||
ParameterKey key{};
|
||||
Color color{};
|
||||
bool eligible{};
|
||||
};
|
||||
struct SavedColor {
|
||||
Binding binding;
|
||||
Color original{}, last{};
|
||||
};
|
||||
inline constexpr std::size_t max_bindings = targets.size() * 4;
|
||||
struct Materials {
|
||||
std::uintptr_t component{}, appearance{};
|
||||
std::array<Binding, max_bindings> bindings{};
|
||||
std::size_t count{};
|
||||
bool pending{};
|
||||
};
|
||||
using developer_hoodie_detail::Materials;
|
||||
template<class Memory>
|
||||
std::uintptr_t parameter(Memory &m, std::uintptr_t material, std::uint64_t key) {
|
||||
const auto buckets = m.ptr(material, 0x268);
|
||||
@@ -85,21 +73,9 @@ template<class Read> Materials materials(Read &read, std::uintptr_t base, std::u
|
||||
using namespace multiplayer;
|
||||
CosmeticMemory<Read &> m{read, base};
|
||||
Materials out;
|
||||
m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "RGB board actor type differs.");
|
||||
m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "Board actor type differs.");
|
||||
out.component = m.component(entity);
|
||||
out.pending = m.template get<std::uint8_t>(out.component, 0x10a) || m.template get<std::uint8_t>(out.component, 0x10d);
|
||||
const auto recipe = m.ptr(out.component, 0x158);
|
||||
const auto count = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots);
|
||||
std::array<bool, targets.size()> eligible{};
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const auto item = m.template get<NativeCosmeticItem>(recipe, i * sizeof(NativeCosmeticItem));
|
||||
for (std::size_t t = 0; t < targets.size(); ++t) {
|
||||
const auto &target = targets[t];
|
||||
if (item.slot != target.recipe_slot || item.hash != cosmetic_asset_hash(target.asset)) continue;
|
||||
const auto name = item.asset ? m.text(reinterpret_cast<std::uintptr_t>(item.asset)) : std::string{};
|
||||
eligible[t] = name.empty() || name == target.asset;
|
||||
}
|
||||
}
|
||||
const auto type = base + addr::native_cosmetics::color_rgb_type;
|
||||
const auto info = m.ptr(type);
|
||||
m.check(info >= base && info < base + 0x09144000 && m.template get<std::uint32_t>(info) == 0x885eff59 &&
|
||||
@@ -126,8 +102,7 @@ template<class Read> Materials materials(Read &read, std::uintptr_t base, std::u
|
||||
if (!total) { out.pending = true; continue; }
|
||||
m.check(buckets && size && size <= 256 && total <= 128, "Board material map exceeds bounds.");
|
||||
const auto sentinel = m.ptr(buckets, size * 8ULL);
|
||||
for (std::size_t t = 0; t < targets.size(); ++t) {
|
||||
const auto &target = targets[t];
|
||||
for (const auto &target : targets) {
|
||||
if (target.controller != slot) continue;
|
||||
const auto hash = material_hash(target.material);
|
||||
auto node = m.ptr(buckets, (hash % size) * 8ULL);
|
||||
@@ -152,62 +127,17 @@ template<class Read> Materials materials(Read &read, std::uintptr_t base, std::u
|
||||
color = m.template get<Color>(p, 0x10);
|
||||
m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }), "Board color is invalid.");
|
||||
}
|
||||
m.check(out.count < out.bindings.size(), "Board colors exceed bounds.");
|
||||
out.bindings[out.count++] = {item, material, p, type, key, color, eligible[t]};
|
||||
m.check(out.bindings.size() < max_bindings, "Board colors exceed bounds.");
|
||||
out.bindings.push_back({item, material, p, key, color, target.part});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
inline bool same_binding(const Binding &live, const Binding &saved) noexcept {
|
||||
// A successful native insertion acquires its node on the next fresh walk.
|
||||
return live.item == saved.item && live.material == saved.material && live.type == saved.type && live.key.hash == saved.key.hash &&
|
||||
(saved.node ? live.node == saved.node : live.node != 0);
|
||||
}
|
||||
} // namespace developer_board_detail
|
||||
struct DeveloperBoardState {
|
||||
std::uintptr_t entity{}, component{}, appearance{};
|
||||
std::uint64_t generation{};
|
||||
std::array<developer_board_detail::SavedColor, developer_board_detail::max_bindings> colors{};
|
||||
std::size_t count{};
|
||||
};
|
||||
namespace developer_board_detail {
|
||||
// Re-discover ownership before every write. Changing the recipe restores only
|
||||
// colors that still equal our last write; native preset updates take precedence.
|
||||
// Missing keys inherit .5 from these compiled board shaders. Keep inserted
|
||||
// overrides at native preset priority and restore that default on deactivation.
|
||||
// What was saved of a board's parts while they are coloured. A key a part's material does not
|
||||
// have inherits .5 from the compiled board shaders: one is added at native preset priority, so
|
||||
// a later native preset replaces it, and goes back to that default when the colouring stops.
|
||||
// Never clear a NativeValue: the renderer still copies the key's full payload.
|
||||
template<class Write, class Publish>
|
||||
void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation,
|
||||
bool developer, std::uint64_t milliseconds, Write &write, Publish &publish) {
|
||||
if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance)
|
||||
state = {};
|
||||
if (live.pending) return;
|
||||
DeveloperBoardState next;
|
||||
next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance;
|
||||
const auto color = rainbow(milliseconds);
|
||||
std::array<std::uintptr_t, max_bindings> changed_items{};
|
||||
std::size_t changed{};
|
||||
for (std::size_t i = 0; i < live.count; ++i) {
|
||||
const auto &binding = live.bindings[i];
|
||||
const SavedColor *saved{};
|
||||
for (std::size_t j = 0; j < state.count; ++j)
|
||||
if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; }
|
||||
bool wrote{};
|
||||
if (developer && binding.eligible) {
|
||||
const auto original = saved && binding.color == saved->last ? saved->original : binding.color;
|
||||
if ((binding.node && binding.color == color) || (wrote = write(binding, color)))
|
||||
next.colors[next.count++] = {binding, original, color};
|
||||
else if (saved) next.colors[next.count++] = *saved;
|
||||
} else if (saved && binding.color == saved->last && binding.color != saved->original) {
|
||||
wrote = write(binding, saved->original);
|
||||
if (!wrote) next.colors[next.count++] = *saved;
|
||||
}
|
||||
if (wrote && std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed)
|
||||
changed_items[changed++] = binding.item;
|
||||
}
|
||||
state = next;
|
||||
for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]);
|
||||
}
|
||||
} // namespace developer_board_detail
|
||||
struct DeveloperBoardState : developer_hoodie_detail::ItemState {};
|
||||
} // namespace dingosdk
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include <Windows.h>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
@@ -23,7 +24,7 @@ void report_hoodie(std::uintptr_t entity, std::uint64_t steam_id, std::string_vi
|
||||
}
|
||||
if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return;
|
||||
*entry = {entity, now, hash};
|
||||
logging::log(level, logging::Channel::customization, "Developer hoodie (Steam {}, actor {:#x}): {}",
|
||||
logging::log(level, logging::Channel::customization, "Skater items (Steam {}, actor {:#x}): {}",
|
||||
steam_id, entity, message);
|
||||
}
|
||||
// NativeValue is owned by this material. Preserve its padding, type, priority,
|
||||
@@ -41,12 +42,34 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept {
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
// What each of the skater's marked cosmetics does for this player: top, bottoms, shoes, ...
|
||||
using Animations = std::array<developer_hoodie_detail::ItemAnimation, developer_hoodie_detail::slots.size()>;
|
||||
Animations skater_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept {
|
||||
const auto mark = multiplayer::identity_mark(steam_id);
|
||||
Animations out;
|
||||
for (std::size_t i = 0; i < out.size(); ++i) out[i] = developer_hoodie_detail::item_animation(mark, styles[i]);
|
||||
return out;
|
||||
}
|
||||
bool any_on(const Animations &animations) noexcept {
|
||||
return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; });
|
||||
}
|
||||
// Which cosmetics are coloured, and how many colours of each: what is equipped decides.
|
||||
std::string coloured(const DeveloperHoodieState &state) {
|
||||
std::array<unsigned, developer_hoodie_detail::slots.size()> colors{};
|
||||
for (const auto &saved : state.colors) ++colors[saved.binding.part];
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < colors.size(); ++i)
|
||||
if (colors[i]) out += std::format("{}{} ({})", out.empty() ? "" : ", ", multiplayer::mark_item_names[i], colors[i]);
|
||||
return out.empty() ? "nothing equipped has a color to change" : "animation active on " + out;
|
||||
}
|
||||
} // namespace
|
||||
void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperHoodieState &state) noexcept {
|
||||
const bool developer = multiplayer::reskate_developer(steam_id);
|
||||
std::uint64_t generation, DeveloperHoodieState &state,
|
||||
const multiplayer::MarkStyles &styles) noexcept {
|
||||
const auto animations = skater_animations(steam_id, styles);
|
||||
const bool listed = any_on(animations);
|
||||
if (!entity) { state = {}; return; }
|
||||
if (!developer && !state.count) return;
|
||||
if (!listed && state.colors.empty()) return;
|
||||
try {
|
||||
auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); };
|
||||
std::array<unsigned char, addr::native_cosmetics::publish_materials_prefix.size()> prefix{};
|
||||
@@ -57,12 +80,10 @@ void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::ui
|
||||
}
|
||||
const auto live = developer_hoodie_detail::materials(read, base, entity);
|
||||
bool published = true;
|
||||
auto publish = [base, &published](std::uintptr_t item) { published = publish_materials(base, item); };
|
||||
developer_hoodie_detail::animate(state, live, entity, generation, developer, GetTickCount64(), write_color, publish);
|
||||
auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; };
|
||||
developer_hoodie_detail::animate(state, live, entity, generation, animations, GetTickCount64(), write_color, publish);
|
||||
if (!published) report_hoodie(entity, steam_id, "native material publication failed", logging::Level::warning);
|
||||
else if (state.count) report_hoodie(entity, steam_id, "RGB active on the owned hoodie material", logging::Level::info);
|
||||
else if (developer && live.eligible)
|
||||
report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning);
|
||||
else if (listed && !live.pending) report_hoodie(entity, steam_id, coloured(state), logging::Level::info);
|
||||
} catch (const std::exception &error) {
|
||||
report_hoodie(entity, steam_id, error.what(), logging::Level::warning);
|
||||
} catch (...) {
|
||||
@@ -77,10 +98,11 @@ void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, boo
|
||||
if (!ready) return;
|
||||
try {
|
||||
const auto social = multiplayer::steam_social_snapshot();
|
||||
const auto id = social ? social->local.id : 0;
|
||||
if (!multiplayer::reskate_developer(id) && !state.count) return;
|
||||
const auto id = social && multiplayer::own_items_shown() ? social->local.id : 0;
|
||||
const auto styles = developer_hoodie_detail::own_styles.load();
|
||||
if (!any_on(skater_animations(id, styles)) && state.colors.empty()) return;
|
||||
const auto local = multiplayer::capture_local(base, client, false);
|
||||
if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state);
|
||||
if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state, styles);
|
||||
else report_hoodie(client, id, local.detail, logging::Level::warning);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
namespace dingosdk {
|
||||
// Verified client update thread only. Remote identity comes from the Steam roster,
|
||||
// generation from the current native actor; neither is a client cosmetic parameter.
|
||||
// Colours what a listed player wears (developer_hoodie_material.h): `styles` is how they have
|
||||
// each marked cosmetic, the skater's first.
|
||||
void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperHoodieState &state) noexcept;
|
||||
std::uint64_t generation, DeveloperHoodieState &state,
|
||||
const multiplayer::MarkStyles &styles) noexcept;
|
||||
void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept;
|
||||
} // namespace dingosdk
|
||||
@@ -1,48 +1,93 @@
|
||||
#pragma once
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include "Extension/Multiplayer/Remote/cosmetics.h"
|
||||
#include "Extension/Multiplayer/Remote/native_cosmetics_layout.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The cosmetics of a player on one of the backend's lists (developer_identity.h) are coloured
|
||||
// for them: whatever they wear and ride, on their own material instances, as they choose on the
|
||||
// menu's Special page. It began as the developers' rainbow hoodie and pink board, which the
|
||||
// names here still say.
|
||||
namespace dingosdk {
|
||||
namespace developer_hoodie_detail {
|
||||
inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001";
|
||||
// The compiled appearance preset names controllers by their assigned CAS slot.
|
||||
// "TopItem" is a Lua construction name, not the live controller's name.
|
||||
inline constexpr std::string_view top_slot = "Characters/MainCharacters/Generic/CAS/Common/Slots/CAS_Top_Slot";
|
||||
// Serialized ParamDbKey identities from this hoodie's AppearanceShaderExpressionPreset.
|
||||
// Only the six fabric color regions: textures, stitches and other clothing stay intact.
|
||||
inline constexpr std::array<std::uint64_t, 6> color_keys{
|
||||
0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL,
|
||||
0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL};
|
||||
// The compiled appearance preset names a skater's item controllers by their assigned CAS slot
|
||||
// ("TopItem" and the like are Lua construction names, not the live controllers' names).
|
||||
inline constexpr std::string_view slot_folder = "Characters/MainCharacters/Generic/CAS/Common/Slots/";
|
||||
// The skater's marked cosmetics, in the order of their styles (multiplayer::mark_item_names):
|
||||
// top, bottoms, shoes, socks, hat, glasses, outfit, costume.
|
||||
inline constexpr std::array<std::string_view, multiplayer::skater_mark_items> slots{
|
||||
"CAS_Top_Slot", "CAS_Bottom_Slot", "CAS_Footwear_Slot", "CAS_Sock_Slot",
|
||||
"CAS_Headgear_Slot", "CAS_Eyewear_Slot", "CAS_Outfit_Slot", "CAS_CostumeFullBody_Slot"};
|
||||
// Serialized ParamDbKey identities of the ColorRgb parameters the game's clothing presets set
|
||||
// (AppearanceShaderExpressionPreset.ParamInfo; read from 114 presets of every kind of item,
|
||||
// 2026-10-04): the colours of an item's regions. Every kind of clothing draws on the same
|
||||
// ones, the first thirteen by far the most, and each item colours some and leaves the rest at
|
||||
// the shaders' neutral grey (`neutral`), where its textures show as they are.
|
||||
// On the relaxed hoodie, as seen in game: the first is the whole body, sleeves and hood; the
|
||||
// fourth the cuffs and hem; the fifth the drawstrings and hood lining.
|
||||
inline constexpr std::array<std::uint64_t, 28> color_keys{
|
||||
0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, 0xf526305270d94cb3ULL,
|
||||
0x99879bf202d1960aULL, 0x998450425dbd9044ULL, 0x18984e85c679da8dULL, 0x2bf96fc9d8da57bbULL,
|
||||
0x13cfb51bd8474129ULL, 0x7c06880748ded394ULL, 0xfca496001ea60718ULL, 0xfca79ddc325249ceULL,
|
||||
0x5e1943a7c20ab77cULL, 0x0adafe3e13df4057ULL, 0xe3ab5f6a33dd1684ULL, 0xc31ad6ca613dcf43ULL,
|
||||
0xc1c70aa67f8bde7cULL, 0xc1c4554f6b58eee8ULL, 0x24e6723cc9f17eccULL, 0xd98b435969485311ULL,
|
||||
0x27e785e804edea5cULL, 0x27e45bf8d67d959fULL, 0xae823ac5acb2abbaULL, 0x89cbc6b088ae9b1bULL,
|
||||
0x593aacb9e12dcd6bULL, 0x4ec6aeb742a2c383ULL, 0xa19416e977675f88ULL, 0x0a94b0e28d102968ULL};
|
||||
using Color = std::array<float, 3>;
|
||||
inline constexpr Color neutral{.5f, .5f, .5f};
|
||||
// Native ParamDbKey, as a material's parameter node holds it. The first word is the final two
|
||||
// serialized GUID bytes; the type index is the live ColorRgb descriptor's.
|
||||
struct ParameterKey {
|
||||
std::uint16_t identity{}, size{}, type{}, count{};
|
||||
std::uint64_t hash{};
|
||||
};
|
||||
static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8);
|
||||
// One colour of one material instance. `part` is which marked cosmetic it belongs to.
|
||||
struct Binding {
|
||||
std::uintptr_t material{}, node{}, type{};
|
||||
std::uint64_t key{};
|
||||
std::uintptr_t item{}, material{}, node{}; // node 0: no such parameter yet (the board adds one)
|
||||
ParameterKey key{};
|
||||
Color color{};
|
||||
bool operator==(const Binding &) const = default;
|
||||
std::uint8_t part{};
|
||||
};
|
||||
struct SavedColor {
|
||||
Binding binding;
|
||||
Color original{}, last{};
|
||||
std::uint8_t settling{}; // publishes still owed for `last` (settle_publishes)
|
||||
};
|
||||
inline constexpr std::size_t max_bindings = color_keys.size() * 4;
|
||||
// The colours found on an actor in one walk.
|
||||
struct Materials {
|
||||
std::uintptr_t component{}, appearance{}, item{};
|
||||
std::array<Binding, max_bindings> bindings{};
|
||||
std::size_t count{};
|
||||
bool eligible{};
|
||||
std::uintptr_t component{}, appearance{};
|
||||
std::vector<Binding> bindings;
|
||||
bool pending{};
|
||||
// Clothing: a colour at `neutral` is a region the item leaves to its textures, and is left
|
||||
// alone, unless the item colours none of its regions: then all of them are coloured.
|
||||
bool regions_only{};
|
||||
};
|
||||
// What was saved of an actor's colours while they are coloured here.
|
||||
struct ItemState {
|
||||
std::uintptr_t entity{}, component{}, appearance{};
|
||||
std::uint64_t generation{};
|
||||
std::vector<SavedColor> colors;
|
||||
};
|
||||
// What is equipped can be anything: this many colours of a skater, and no more.
|
||||
inline constexpr std::size_t max_bindings = 512;
|
||||
inline std::uint32_t material_hash(std::string_view name) noexcept {
|
||||
std::uint32_t hash = 5381;
|
||||
for (auto c : name) hash = hash * 33 ^ static_cast<unsigned char>(c >= 'A' && c <= 'Z' ? c + 32 : c);
|
||||
return hash;
|
||||
}
|
||||
inline Color rainbow(std::uint64_t milliseconds) noexcept {
|
||||
// One continuous hue cycle every eight seconds, in the material's existing gamut.
|
||||
const auto hue = static_cast<float>(milliseconds % 8000) * (6.f / 8000.f);
|
||||
inline Color rainbow(std::uint64_t milliseconds, std::uint64_t period = 8000) noexcept {
|
||||
// One continuous hue cycle every eight seconds (or `period`), in the material's existing gamut.
|
||||
const auto hue = static_cast<float>(milliseconds % period) * (6.f / static_cast<float>(period));
|
||||
const auto sector = static_cast<unsigned>(hue);
|
||||
constexpr float high = .8f, low = .025f;
|
||||
const auto rising = low + (high - low) * (hue - static_cast<float>(sector));
|
||||
@@ -56,126 +101,276 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept {
|
||||
default: return {high, low, falling};
|
||||
}
|
||||
}
|
||||
// What one of a listed player's marked cosmetics does right now: nothing, the rainbow, or a
|
||||
// walk through three colours and back (one colour three times stands still).
|
||||
struct ItemAnimation {
|
||||
bool on{}, rainbow{};
|
||||
std::array<Color, 3> stops{};
|
||||
std::uint64_t period = 3000; // milliseconds for a round
|
||||
};
|
||||
// A colour as picked on screen, as the material takes it: linear, and inside the gamut the
|
||||
// rainbow keeps to.
|
||||
inline Color material_color(const std::array<std::uint8_t, 3> &picked) noexcept {
|
||||
Color out{};
|
||||
for (std::size_t i = 0; i < out.size(); ++i)
|
||||
out[i] = std::max(.004f, std::pow(static_cast<float>(picked[i]) / 255.f, 2.2f) * .8f);
|
||||
return out;
|
||||
}
|
||||
// The colours the pickers start from: about where each list's own animation begins and ends.
|
||||
inline std::pair<std::array<std::uint8_t, 3>, std::array<std::uint8_t, 3>> standard_picks(multiplayer::IdentityList mark) noexcept {
|
||||
using multiplayer::IdentityList;
|
||||
if (mark == IdentityList::content_creator) return {{0x88, 0x23, 0x23}, {0xff, 0x48, 0x70}};
|
||||
if (mark == IdentityList::homie) return {{0xad, 0x6c, 0x1c}, {0xff, 0xe6, 0x67}};
|
||||
if (mark == IdentityList::centrix) return {{0x1f, 0x7b, 0xff}, {0xff, 0xff, 0xff}};
|
||||
if (mark == IdentityList::staff) return {{0x10, 0x80, 0x38}, {0x6a, 0xf0, 0x8c}};
|
||||
return {{0x6e, 0x20, 0xff}, {0xb4, 0x78, 0xff}};
|
||||
}
|
||||
// What a cosmetic does for a player on `mark`'s list (nobody else's does anything) with the
|
||||
// style they chose on the Special page. Left standard, a developer's cycles the rainbow, a
|
||||
// content creator's goes from deep red through bright red to a reddish pink and back, and a
|
||||
// homie's from deep gold through bright gold to yellow and back, Centrix's from blue
|
||||
// through light blue to white and back, and the staff's from deep green through bright green
|
||||
// to a light one and back. The rainbow is a developer's standard, and a style a developer or
|
||||
// a member of staff can pick (rainbow_style); nobody else's item takes it.
|
||||
inline ItemAnimation item_animation(std::optional<multiplayer::IdentityList> mark, const multiplayer::MarkStyle &style) noexcept {
|
||||
using multiplayer::IdentityList;
|
||||
using multiplayer::MarkMode;
|
||||
ItemAnimation out;
|
||||
if (!mark || style.mode == MarkMode::off) return out;
|
||||
out.on = true;
|
||||
out.rainbow = (style.mode == MarkMode::standard && *mark == IdentityList::developer) ||
|
||||
(multiplayer::rainbow_style(style) && (*mark == IdentityList::developer || *mark == IdentityList::staff));
|
||||
const std::uint64_t normal = out.rainbow ? 8000 : 3000;
|
||||
out.period = style.speed == 1 ? normal * 2 : style.speed == 2 ? normal / 2 : normal;
|
||||
if (out.rainbow) {
|
||||
} else if (style.mode == MarkMode::solid) {
|
||||
out.stops.fill(material_color(style.from));
|
||||
} else if (style.mode == MarkMode::gradient) {
|
||||
const auto from = material_color(style.from), to = material_color(style.to);
|
||||
out.stops = {from, Color{(from[0] + to[0]) * .5f, (from[1] + to[1]) * .5f, (from[2] + to[2]) * .5f}, to};
|
||||
} else if (!out.rainbow) {
|
||||
// One colour getting lighter and darker is hard to see, so each goes on into the colour
|
||||
// next to it: a deep shade, a bright one, then a reddish pink or a yellow.
|
||||
constexpr std::array<Color, 3> red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}};
|
||||
constexpr std::array<Color, 3> gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}};
|
||||
constexpr std::array<Color, 3> blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}};
|
||||
constexpr std::array<Color, 3> green{{{.006f, .2f, .04f}, {.05f, .8f, .2f}, {.35f, .8f, .4f}}};
|
||||
out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue :
|
||||
*mark == IdentityList::staff ? green : gold;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
inline Color item_color(const ItemAnimation &animation, std::uint64_t milliseconds) noexcept {
|
||||
if (animation.rainbow) return rainbow(milliseconds, animation.period);
|
||||
// 0 to 2 and back, once a round.
|
||||
const auto along = 1.f - std::cos(static_cast<float>(milliseconds % animation.period) *
|
||||
(6.2831853f / static_cast<float>(animation.period)));
|
||||
const auto &from = animation.stops[along < 1.f ? 0 : 1], &to = animation.stops[along < 1.f ? 1 : 2];
|
||||
const auto amount = along < 1.f ? along : along - 1.f;
|
||||
return {from[0] + (to[0] - from[0]) * amount, from[1] + (to[1] - from[1]) * amount,
|
||||
from[2] + (to[2] - from[2]) * amount};
|
||||
}
|
||||
// The local player's own styles (the Special page): sent with their appearance, and kept in
|
||||
// their profile as text, two hex digits a byte.
|
||||
inline std::atomic<multiplayer::MarkStyles> own_styles{};
|
||||
inline std::string mark_styles_text(const multiplayer::MarkStyles &styles) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string text;
|
||||
const auto add = [&](std::uint8_t byte) { text += digits[byte >> 4], text += digits[byte & 15]; };
|
||||
for (const auto &style : styles) {
|
||||
add(static_cast<std::uint8_t>(style.mode));
|
||||
for (const auto part : style.from) add(part);
|
||||
for (const auto part : style.to) add(part);
|
||||
add(style.speed);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
inline std::optional<multiplayer::MarkStyles> parse_mark_styles(std::string_view text) noexcept {
|
||||
multiplayer::MarkStyles styles{};
|
||||
if (text.size() != styles.size() * 16) return std::nullopt;
|
||||
std::size_t at = 0;
|
||||
bool valid = true;
|
||||
const auto next = [&]() -> std::uint8_t {
|
||||
unsigned byte = 0;
|
||||
for (int digit = 0; digit < 2; ++digit) {
|
||||
const char c = text[at++];
|
||||
if (c >= '0' && c <= '9') byte = byte * 16 + static_cast<unsigned>(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') byte = byte * 16 + static_cast<unsigned>(c - 'a' + 10);
|
||||
else valid = false;
|
||||
}
|
||||
return static_cast<std::uint8_t>(byte);
|
||||
};
|
||||
for (auto &style : styles) {
|
||||
style.mode = static_cast<multiplayer::MarkMode>(next());
|
||||
for (auto &part : style.from) part = next();
|
||||
for (auto &part : style.to) part = next();
|
||||
style.speed = next();
|
||||
valid = valid && multiplayer::valid_mark_style(style);
|
||||
}
|
||||
return valid ? std::optional(styles) : std::nullopt;
|
||||
}
|
||||
// A material instance's own parameters: a hash map of typed nodes.
|
||||
struct ParameterMap {
|
||||
std::uintptr_t buckets{}, stop{};
|
||||
std::uint32_t size{}, total{};
|
||||
};
|
||||
template<class Memory> ParameterMap parameter_map(Memory &m, std::uintptr_t material) {
|
||||
ParameterMap out{m.ptr(material, 0x268), 0, m.template get<std::uint32_t>(material, 0x270),
|
||||
m.template get<std::uint32_t>(material, 0x274)};
|
||||
if (!out.buckets || !out.size || out.size > 1024 || !out.total || out.total > 512) return {};
|
||||
out.stop = m.ptr(out.buckets, out.size * 8ULL);
|
||||
return out;
|
||||
}
|
||||
// The node of a material's ColorRgb parameter with this key, or 0. The EBX boxed ColorRgb has a
|
||||
// serialized gamut word; the loaded native ColorRgb is three floats (12 bytes): this goes by
|
||||
// the actual type descriptor, not the serialized asset size. A parameter of another shape is
|
||||
// passed over, not reported: what is equipped can be anything.
|
||||
template<class Memory>
|
||||
std::uintptr_t color_parameter(Memory &m, std::uintptr_t base, const ParameterMap &map, std::uint64_t key, ParameterKey &native,
|
||||
Color &color) {
|
||||
if (!map.size) return 0;
|
||||
auto node = m.ptr(map.buckets, (key % map.size) * 8ULL);
|
||||
for (std::uint32_t visited = 0; node && node != map.stop && visited < map.total; ++visited, node = m.ptr(node, 0x40)) {
|
||||
if (m.template get<std::uint64_t>(node, 8) != key) continue;
|
||||
native = m.template get<ParameterKey>(node);
|
||||
const auto type = m.ptr(node, 0x20);
|
||||
if (type < base || type >= base + 0x09144000 || native.size != sizeof(Color) || native.count != 1 ||
|
||||
!(m.template get<std::uint32_t>(node, 0x28) & 1) || m.template get<std::uint16_t>(type, 8) != native.type)
|
||||
return 0;
|
||||
const auto info = m.ptr(type);
|
||||
if (m.template get<std::uint32_t>(info) != 0x885eff59U || m.template get<std::uint16_t>(info, 6) != sizeof(Color)) return 0;
|
||||
color = m.template get<Color>(node, 0x10);
|
||||
return std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) ? node : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// All discovery is bounded and follows the actor's own component/controller/material
|
||||
// backlinks. Never edit the shared AppearanceShaderExpressionPreset or recipe.
|
||||
template<class Read> Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) {
|
||||
using namespace multiplayer;
|
||||
CosmeticMemory<Read &> m{read, base};
|
||||
Materials out;
|
||||
m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Hoodie actor type differs.");
|
||||
out.regions_only = true;
|
||||
m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Skater actor type differs.");
|
||||
out.component = m.component(entity);
|
||||
const bool pending = m.template get<std::uint8_t>(out.component, 0x10a) || m.template get<std::uint8_t>(out.component, 0x10d);
|
||||
const auto recipe = m.ptr(out.component, 0x158);
|
||||
const auto items = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots);
|
||||
const auto wanted = cosmetic_asset_hash(asset);
|
||||
for (std::size_t i = 0; i < items; ++i) {
|
||||
const auto item = m.template get<NativeCosmeticItem>(recipe, i * sizeof(NativeCosmeticItem));
|
||||
if (item.hash == wanted) {
|
||||
// Native recipes may store only the hash (an empty CString).
|
||||
const auto name = m.text(reinterpret_cast<std::uintptr_t>(item.asset));
|
||||
out.eligible = !pending && (name.empty() || name == asset);
|
||||
}
|
||||
}
|
||||
out.pending = m.template get<std::uint8_t>(out.component, 0x10a) || m.template get<std::uint8_t>(out.component, 0x10d);
|
||||
out.appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable);
|
||||
const auto begin = m.ptr(out.appearance, 0x90), end = m.ptr(out.appearance, 0x98), capacity = m.ptr(out.appearance, 0xa0);
|
||||
m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) % 8 == 0 &&
|
||||
(capacity - begin) / 8 <= 64 && (begin || !capacity), "Hoodie controller array exceeds bounds.");
|
||||
(capacity - begin) / 8 <= 64 && (begin || !capacity), "Skater controller array exceeds bounds.");
|
||||
for (auto entry = begin; entry < end; entry += 8) {
|
||||
const auto item = m.ptr(entry);
|
||||
if (!item || m.ptr(item, 0x48) != out.appearance + 0x40 || m.text(m.ptr(item, 0x30)) != top_slot) continue;
|
||||
m.check(!out.item, "Hoodie has multiple top controllers.");
|
||||
out.item = item;
|
||||
if (!(m.template get<std::uint32_t>(item, 0xb0) & 2)) { out.eligible = false; continue; }
|
||||
if (!item || m.ptr(item, 0x48) != out.appearance + 0x40) continue;
|
||||
const auto name = m.text(m.ptr(item, 0x30));
|
||||
if (!name.starts_with(slot_folder)) continue;
|
||||
const auto slot = std::find(slots.begin(), slots.end(), std::string_view(name).substr(slot_folder.size()));
|
||||
if (slot == slots.end()) continue;
|
||||
const auto part = static_cast<std::size_t>(slot - slots.begin());
|
||||
// A slot with nothing in it, or with an item still on its way, has no materials.
|
||||
if (!(m.template get<std::uint32_t>(item, 0xb0) & 2)) continue;
|
||||
const auto buckets = m.ptr(item, 0x1f0);
|
||||
const auto size = m.template get<std::uint32_t>(item, 0x1f8), total = m.template get<std::uint32_t>(item, 0x1fc);
|
||||
if (!total) continue;
|
||||
m.check(size && size <= 256 && total <= 128, "Hoodie material map exceeds bounds.");
|
||||
const auto sentinel = m.ptr(buckets, size * 8ULL);
|
||||
const auto hash = material_hash("Top_mat");
|
||||
auto node = m.ptr(buckets, (hash % size) * 8ULL);
|
||||
for (std::uint32_t visited = 0; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28)) {
|
||||
if (m.template get<std::uint32_t>(node) != hash) continue;
|
||||
std::array<std::uintptr_t, 4> seen{};
|
||||
for (std::size_t variant = 0; variant < seen.size(); ++variant) {
|
||||
const auto material = m.ptr(node, 8 + variant * 8);
|
||||
if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue;
|
||||
seen[variant] = material;
|
||||
const auto params = m.ptr(material, 0x268);
|
||||
const auto n = m.template get<std::uint32_t>(material, 0x270), count = m.template get<std::uint32_t>(material, 0x274);
|
||||
if (!count) continue;
|
||||
m.check(n && n <= 1024 && count <= 512, "Hoodie parameter map exceeds bounds.");
|
||||
const auto stop = m.ptr(params, n * 8ULL);
|
||||
for (const auto key : color_keys) {
|
||||
auto parameter = m.ptr(params, (key % n) * 8ULL);
|
||||
for (std::uint32_t v = 0; parameter && parameter != stop && v < count; ++v, parameter = m.ptr(parameter, 0x40)) {
|
||||
if (m.template get<std::uint64_t>(parameter, 8) != key) continue;
|
||||
// The EBX boxed ColorRgb has a serialized gamut word; the
|
||||
// loaded native ColorRgb is three floats (12 bytes). Validate
|
||||
// the actual type descriptor, not the serialized asset size.
|
||||
const auto type = m.ptr(parameter, 0x20);
|
||||
m.check(type >= base && type < base + 0x09144000 &&
|
||||
m.template get<std::uint16_t>(parameter, 2) == sizeof(Color) &&
|
||||
m.template get<std::uint32_t>(m.ptr(type)) == 0x885eff59U &&
|
||||
m.template get<std::uint16_t>(m.ptr(type), 6) == sizeof(Color) && m.template get<std::uint16_t>(parameter, 6) == 1 &&
|
||||
(m.template get<std::uint32_t>(parameter, 0x28) & 1) &&
|
||||
m.template get<std::uint16_t>(type, 8) == m.template get<std::uint16_t>(parameter, 4),
|
||||
"Hoodie color parameter type differs.");
|
||||
const auto color = m.template get<Color>(parameter, 0x10);
|
||||
m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) &&
|
||||
out.count < out.bindings.size(), "Hoodie color exceeds bounds.");
|
||||
out.bindings[out.count++] = {material, parameter, type, key, color};
|
||||
break;
|
||||
m.check(buckets && size && size <= 256 && total <= 128, "Skater material map exceeds bounds.");
|
||||
// Every named material of the item, whatever its mesh calls them ("Top_mat", "Bottom_mat", ...).
|
||||
std::array<std::uintptr_t, 257> heads{};
|
||||
m.check(read(buckets, heads.data(), (size + 1ULL) * 8), "Skater material map cannot be read.");
|
||||
std::uint32_t visited{};
|
||||
for (std::uint32_t bucket = 0; bucket < size; ++bucket) {
|
||||
for (auto node = heads[bucket]; node && node != heads[size]; node = m.ptr(node, 0x28)) {
|
||||
if (m.template get<std::uint32_t>(node) % size != bucket) break; // the next bucket's
|
||||
m.check(visited++ < total, "Skater material chain exceeds bounds.");
|
||||
std::array<std::uintptr_t, 4> seen{};
|
||||
for (std::size_t variant = 0; variant < seen.size(); ++variant) {
|
||||
const auto material = m.ptr(node, 8 + variant * 8);
|
||||
if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue;
|
||||
seen[variant] = material;
|
||||
const auto map = parameter_map(m, material);
|
||||
for (const auto key : color_keys) {
|
||||
if (out.bindings.size() == max_bindings) break;
|
||||
ParameterKey native;
|
||||
Color color{};
|
||||
if (const auto parameter = color_parameter(m, base, map, key, native, color))
|
||||
out.bindings.push_back({item, material, parameter, native, color, static_cast<std::uint8_t>(part)});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
inline bool same_binding(const Binding &a, const Binding &b) noexcept {
|
||||
return a.material == b.material && a.node == b.node && a.type == b.type && a.key == b.key;
|
||||
inline bool same_binding(const Binding &live, const Binding &saved) noexcept {
|
||||
// A parameter the board added has its node from the next fresh walk on.
|
||||
return live.item == saved.item && live.material == saved.material && live.key.hash == saved.key.hash &&
|
||||
(saved.node ? live.node == saved.node : live.node != 0);
|
||||
}
|
||||
} // namespace developer_hoodie_detail
|
||||
struct DeveloperHoodieState {
|
||||
std::uintptr_t entity{}, component{}, appearance{}, item{};
|
||||
std::uint64_t generation{};
|
||||
std::array<developer_hoodie_detail::SavedColor, developer_hoodie_detail::max_bindings> colors{};
|
||||
std::size_t count{};
|
||||
};
|
||||
namespace developer_hoodie_detail {
|
||||
// Writes are only to freshly rediscovered bindings. Restoration additionally
|
||||
// requires the current color to still be ours; a new native preset takes precedence.
|
||||
template<class Write, class Publish>
|
||||
void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation,
|
||||
bool developer, std::uint64_t milliseconds, Write &write, Publish &publish) {
|
||||
if (state.entity != entity || state.generation != generation || state.component != live.component ||
|
||||
state.appearance != live.appearance || state.item != live.item) state = {};
|
||||
const bool enabled = developer && live.eligible;
|
||||
const auto color = rainbow(milliseconds);
|
||||
DeveloperHoodieState next;
|
||||
if (enabled) {
|
||||
next.entity = entity; next.generation = generation; next.component = live.component;
|
||||
next.appearance = live.appearance; next.item = live.item;
|
||||
}
|
||||
bool changed{};
|
||||
for (std::size_t i = 0; i < live.count; ++i) {
|
||||
// The renderer shows a color one publish late. While a color keeps changing the next tick's
|
||||
// publish brings it; one that stands still (a solid color, or the item's own once the
|
||||
// animation is off) needs publishing again after the write that set it, or the item keeps the
|
||||
// color before it on screen (seen in game, 2026-10-04).
|
||||
inline constexpr std::uint8_t settle_publishes = 3;
|
||||
// Ownership is discovered afresh before every write, and writes go only to what that walk
|
||||
// found. A color is restored only while it is still the one written here: a new native
|
||||
// preset takes precedence. `animations`: what each part does, or nothing.
|
||||
template<std::size_t Parts, class Write, class Publish>
|
||||
void animate(ItemState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation,
|
||||
const std::array<ItemAnimation, Parts> &animations, std::uint64_t milliseconds, Write &write, Publish &publish) {
|
||||
if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance)
|
||||
state = {};
|
||||
// Pausing, loading and a recipe on its way keep what was saved until a walk succeeds.
|
||||
if (live.pending) return;
|
||||
// What was saved of each color found: a walk finds them in the same order as the last.
|
||||
std::vector<const SavedColor *> saved(live.bindings.size());
|
||||
std::array<bool, Parts> regions{};
|
||||
for (std::size_t i = 0, from = 0; i < live.bindings.size(); ++i) {
|
||||
const auto &binding = live.bindings[i];
|
||||
const SavedColor *saved{};
|
||||
for (std::size_t j = 0; j < state.count; ++j)
|
||||
if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; }
|
||||
if (enabled) {
|
||||
const auto original = saved && binding.color == saved->last ? saved->original : binding.color;
|
||||
if (binding.color == color || write(binding, color)) {
|
||||
next.colors[next.count++] = {binding, original, color};
|
||||
changed = changed || binding.color != color;
|
||||
}
|
||||
} else if (saved && binding.color == saved->last && binding.color != saved->original) {
|
||||
changed = write(binding, saved->original) || changed;
|
||||
for (std::size_t n = 0; n < state.colors.size(); ++n) {
|
||||
const auto at = (from + n) % state.colors.size();
|
||||
if (same_binding(binding, state.colors[at].binding)) { saved[i] = &state.colors[at], from = at + 1; break; }
|
||||
}
|
||||
const auto &own = saved[i] && binding.color == saved[i]->last ? saved[i]->original : binding.color;
|
||||
regions[binding.part] = regions[binding.part] || own != neutral;
|
||||
}
|
||||
state = next;
|
||||
if (changed) publish(live.item);
|
||||
ItemState next;
|
||||
next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance;
|
||||
next.colors.reserve(live.bindings.size());
|
||||
std::array<std::uintptr_t, 64> changed_items{};
|
||||
std::size_t changed{};
|
||||
for (std::size_t i = 0; i < live.bindings.size(); ++i) {
|
||||
const auto &binding = live.bindings[i];
|
||||
const auto *was = saved[i];
|
||||
const auto &animation = animations[binding.part];
|
||||
const bool ours = was && binding.color == was->last;
|
||||
const auto original = ours ? was->original : binding.color;
|
||||
bool show{};
|
||||
if (animation.on && !(live.regions_only && regions[binding.part] && original == neutral)) {
|
||||
const auto color = item_color(animation, milliseconds);
|
||||
if (binding.node && binding.color == color) {
|
||||
// Already this color: nothing to write, but it may still be owed to the renderer.
|
||||
const std::uint8_t left = ours ? was->settling : 0;
|
||||
next.colors.push_back({binding, original, color, static_cast<std::uint8_t>(left ? left - 1 : 0)});
|
||||
show = left != 0;
|
||||
} else if (write(binding, color)) {
|
||||
next.colors.push_back({binding, original, color, settle_publishes});
|
||||
show = true;
|
||||
} else if (was) next.colors.push_back(*was);
|
||||
} else if (was && (binding.color == was->last || binding.color == was->original)) {
|
||||
// Back to the item's own color, and published until it shows.
|
||||
const bool first = was->last != was->original;
|
||||
show = binding.color == was->original || write(binding, was->original);
|
||||
const std::uint8_t left = first ? settle_publishes : was->settling ? was->settling - 1 : 0;
|
||||
if (!show) next.colors.push_back(*was);
|
||||
else if (left) next.colors.push_back({binding, was->original, was->original, left});
|
||||
}
|
||||
if (show && changed < changed_items.size() &&
|
||||
std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed)
|
||||
changed_items[changed++] = binding.item;
|
||||
}
|
||||
state.entity = next.entity, state.generation = next.generation, state.component = next.component, state.appearance = next.appearance;
|
||||
state.colors = std::move(next.colors);
|
||||
for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]);
|
||||
}
|
||||
} // namespace developer_hoodie_detail
|
||||
// What was saved of a skater's items while they are coloured.
|
||||
struct DeveloperHoodieState : developer_hoodie_detail::ItemState {};
|
||||
} // namespace dingosdk
|
||||
@@ -20,8 +20,7 @@ bool cosmetic_diagnostic(const char* operation, const char* reason, std::string_
|
||||
// ..._rejected are), so a player's ordinary log says why; the rest are routine.
|
||||
const std::string_view why(reason);
|
||||
const bool routine = why == "callback_entered" || why == "unchanged" || why == "no_saved_loadout" ||
|
||||
why == "card_only_preset_recovered" || why == "card_is_not_skater_preset" ||
|
||||
why == "retried_after_catalog_ready";
|
||||
why == "card_only_preset_recovered" || why == "card_is_not_skater_preset";
|
||||
const dingosdk::Json event{{"event", routine ? "local_cosmetic_loadout_diagnostic" : "local_cosmetic_loadout_rejected"},
|
||||
{"operation", operation}, {"reason", reason}, {"preset", id}};
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.dump().c_str());
|
||||
@@ -298,20 +297,14 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou
|
||||
return true;
|
||||
}
|
||||
|
||||
// The loadout manager's record array, or false when it cannot be read. The
|
||||
// same shape the save hook demands of a record it is handed.
|
||||
bool loadout_array(std::uintptr_t& manager, std::uintptr_t& begin, std::uintptr_t& end) {
|
||||
auto& s = local_runtime();
|
||||
return read(s.base + addr::engine::loadout_manager, manager) && manager &&
|
||||
read(manager + 0x18, begin) && read(manager + 0x20, end) && begin <= end &&
|
||||
(end - begin) % 0x48 == 0 && (end - begin) / 0x48 <= 10;
|
||||
}
|
||||
|
||||
// A load, taking the preset id instead of the game's wrapper so that
|
||||
// retry_pending_cosmetic_loads can run it again. The caller holds native_mutex.
|
||||
bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
bool load_cosmetic_hook(const void* wrapper, void* destination) {
|
||||
auto& s = local_runtime();
|
||||
if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination);
|
||||
PreserveError preserve;
|
||||
std::lock_guard lock(s.native_mutex);
|
||||
std::string id;
|
||||
try {
|
||||
if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id");
|
||||
const auto saved = s.store->cosmetic_loadout(id);
|
||||
if (!saved) {
|
||||
profile::CosmeticLoadout starter; std::vector<CosmeticNativeRecipe> native;
|
||||
@@ -331,15 +324,14 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
profile::CosmeticLoadout defaults; std::vector<CosmeticNativeRecipe> native;
|
||||
if (!read_cosmetic_recipes(destination, defaults, native)) return cosmetic_diagnostic("load", "invalid_native_recipe", id);
|
||||
if (recover_card_only_preset(id, *saved, defaults)) return false;
|
||||
if (!refresh_cosmetic_catalog()) {
|
||||
// The cosmetics manager is not ready this early in a level load.
|
||||
// The preset stays blocked, so nothing is written over the outfit
|
||||
// on disk, and the pump applies this record once it is ready --
|
||||
// but only while this is still the array the record came from.
|
||||
CosmeticRuntime::PendingLoad pending{destination};
|
||||
if (loadout_array(pending.manager, pending.begin, pending.end)) c.pending_loads[id] = pending;
|
||||
return cosmetic_diagnostic("load", "catalog_not_ready", id);
|
||||
}
|
||||
// The cosmetics manager cannot be read for the first seconds of a level
|
||||
// load, which is when the game asks for every slot up to the selected
|
||||
// one. The catalog read before the load says the same thing (what is
|
||||
// installed does not change with the level), so the outfit is checked
|
||||
// against that one. Only with no catalog read yet this session is the
|
||||
// load refused; the selection that asks is held back until one is
|
||||
// (local_customization_outfits_loadable).
|
||||
if (!refresh_cosmetic_catalog() && c.items.empty()) return cosmetic_diagnostic("load", "catalog_not_ready", id);
|
||||
if (saved->recipes.size() != defaults.recipes.size()) return cosmetic_diagnostic("load", "recipe_count_mismatch", id);
|
||||
const auto snapshot_shared = s.store->shared_snapshot();
|
||||
const auto& snapshot = *snapshot_shared;
|
||||
@@ -350,7 +342,7 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
for (std::size_t i = 0; i < saved->recipes.size(); ++i) {
|
||||
const auto& recipe = saved->recipes[i]; const auto& expected = defaults.recipes[i];
|
||||
if (recipe.template_key != expected.template_key || recipe.template_version != expected.template_version ||
|
||||
recipe.scalar_bits.size() != expected.scalar_bits.size() || recipe.items.size() != expected.items.size())
|
||||
recipe.scalar_bits.size() != expected.scalar_bits.size())
|
||||
return cosmetic_diagnostic("load", "recipe_template_mismatch", id);
|
||||
std::map<std::uint32_t, std::uint32_t> slot_categories;
|
||||
if (!cosmetic_slot_categories(native[i].resource, slot_categories))
|
||||
@@ -358,11 +350,23 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
words.emplace_back(recipe.scalar_bits.size());
|
||||
std::copy(recipe.scalar_bits.begin(), recipe.scalar_bits.end(), words.back().data());
|
||||
native[i].scalars = words.back().data();
|
||||
item_arrays.emplace_back(recipe.items.size());
|
||||
for (std::size_t j = 0; j < recipe.items.size(); ++j) {
|
||||
const auto* slot = &recipe.items[j];
|
||||
if (slot->slot != expected.items[j].slot) return cosmetic_diagnostic("load", "slot_mismatch", id);
|
||||
if (!slot->asset.empty()) {
|
||||
// The template says which slots there are and in what order; the saved
|
||||
// outfit is matched to it slot by slot. A mod that adds slots to a
|
||||
// template (a wheel slot per wheel on the board) changes that list
|
||||
// under every outfit saved before it, and again when it is switched
|
||||
// off: a slot the outfit has no item for starts on its default, and a
|
||||
// saved slot the template no longer has is held, so the next save
|
||||
// keeps it for when the slot is back.
|
||||
std::map<std::uint32_t, const profile::CosmeticSlot*> saved_slots;
|
||||
for (const auto& item : recipe.items) saved_slots.emplace(item.slot, &item);
|
||||
std::size_t added{};
|
||||
item_arrays.emplace_back(expected.items.size());
|
||||
for (std::size_t j = 0; j < expected.items.size(); ++j) {
|
||||
const auto match = saved_slots.find(expected.items[j].slot);
|
||||
const auto* slot = match == saved_slots.end() ? &expected.items[j] : match->second;
|
||||
if (match == saved_slots.end()) ++added;
|
||||
else saved_slots.erase(match);
|
||||
if (slot != &expected.items[j] && !slot->asset.empty()) {
|
||||
// An item that is no longer installed (a removed mod, a catalog
|
||||
// change) or no longer fits its slot falls back to the slot's
|
||||
// default. Rejecting the whole outfit would also block every
|
||||
@@ -387,13 +391,19 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
}
|
||||
words.emplace_back(slot->parameter_bits.size());
|
||||
std::copy(slot->parameter_bits.begin(), slot->parameter_bits.end(), words.back().data());
|
||||
item_arrays.back().data()[j] = {slot->asset.c_str(), words.back().data(), cosmetic_hash(slot->asset), slot->slot};
|
||||
item_arrays.back().data()[j] = {slot->asset.c_str(), words.back().data(), cosmetic_hash(slot->asset),
|
||||
expected.items[j].slot};
|
||||
}
|
||||
for (const auto& [hash, item] : saved_slots) held.push_back({i, *item, {}});
|
||||
if (added || !saved_slots.empty())
|
||||
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::customization,
|
||||
"Saved outfit \"{}\": its slots differ from the game's (a mod that adds or removes slots): {} "
|
||||
"new slot(s) start on their default, {} saved slot(s) the game does not have now are kept.",
|
||||
id, added, saved_slots.size());
|
||||
native[i].items = item_arrays.back().data();
|
||||
}
|
||||
s.copy_cosmetic(destination, native.data(), native.data() + native.size());
|
||||
c.blocked_loadouts.erase(id);
|
||||
c.pending_loads.erase(id);
|
||||
if (held.empty()) c.held_slots.erase(id);
|
||||
else c.held_slots[id] = std::move(held);
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_loaded\"}");
|
||||
@@ -405,44 +415,6 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
|
||||
}
|
||||
}
|
||||
|
||||
bool load_cosmetic_hook(const void* wrapper, void* destination) {
|
||||
auto& s = local_runtime();
|
||||
if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination);
|
||||
PreserveError preserve;
|
||||
std::lock_guard lock(s.native_mutex);
|
||||
std::string id;
|
||||
try {
|
||||
if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id");
|
||||
} catch (...) {
|
||||
return cosmetic_diagnostic("load", "invalid_preset_id");
|
||||
}
|
||||
return apply_saved_loadout(id, destination);
|
||||
}
|
||||
|
||||
void retry_pending_cosmetic_loads() {
|
||||
auto& c = cosmetic_runtime();
|
||||
if (c.pending_loads.empty()) return;
|
||||
std::uintptr_t manager{}, begin{}, end{};
|
||||
// Unreadable now: keep the records and come back, rather than losing them.
|
||||
if (!loadout_array(manager, begin, end)) return;
|
||||
auto pending = std::move(c.pending_loads);
|
||||
c.pending_loads.clear();
|
||||
for (const auto& [id, load] : pending) {
|
||||
const auto p = reinterpret_cast<std::uintptr_t>(load.destination);
|
||||
// The same array, the same bounds, and the record still inside it on
|
||||
// its stride. Anything else and this address may now hold another
|
||||
// preset: give up on the outfit rather than write it over a different
|
||||
// saved character.
|
||||
if (!load.destination || manager != load.manager || begin != load.begin || end != load.end ||
|
||||
p < begin || p >= end || (p - begin) % 0x48) {
|
||||
cosmetic_diagnostic("load", "retry_record_moved", id);
|
||||
continue;
|
||||
}
|
||||
if (apply_saved_loadout(id, load.destination))
|
||||
cosmetic_diagnostic("load", "retried_after_catalog_ready", id);
|
||||
}
|
||||
}
|
||||
|
||||
void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only) {
|
||||
auto& s = local_runtime();
|
||||
if (!s.active.load(std::memory_order_acquire)) { s.save_cosmetic(manager, record, raw_id, reason, changed_only); return; }
|
||||
@@ -470,9 +442,10 @@ void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id
|
||||
if (value.recipes.size() == 1 && value.recipes.front().template_key == 2169419386U) {
|
||||
cosmetic_diagnostic("save", "card_is_not_skater_preset", id); return;
|
||||
}
|
||||
// Keep items from costume mods that are not installed right now: a slot
|
||||
// still showing the stand-in default saves the original item, while a
|
||||
// slot the player changed saves the new choice and releases the hold.
|
||||
// Keep items from costume mods that are not installed right now, and
|
||||
// whole slots a mod added that is switched off: a slot still showing the
|
||||
// stand-in default saves the original item, while a slot the player
|
||||
// changed saves the new choice and releases the hold.
|
||||
if (const auto held = cosmetic_runtime().held_slots.find(id); held != cosmetic_runtime().held_slots.end()) {
|
||||
std::erase_if(held->second, [&](const CosmeticRuntime::HeldSlot& h) {
|
||||
if (h.recipe >= value.recipes.size()) return true;
|
||||
@@ -482,7 +455,10 @@ void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id
|
||||
item = h.saved;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// The game has no such slot now (the mod that added it is off):
|
||||
// the saved item rides along until the slot is back.
|
||||
value.recipes[h.recipe].items.push_back(h.saved);
|
||||
return false;
|
||||
});
|
||||
if (held->second.empty()) cosmetic_runtime().held_slots.erase(held);
|
||||
}
|
||||
|
||||
@@ -15,27 +15,15 @@ struct CosmeticRuntime {
|
||||
std::uintptr_t item_manager{};
|
||||
std::uint32_t item_count{};
|
||||
std::map<std::string, CosmeticItemInfo, std::less<>> items;
|
||||
// Presets whose last load was refused. A save to one is refused too, so the
|
||||
// standard skater the game shows in its place is never written over the
|
||||
// outfit on disk.
|
||||
std::set<std::string> blocked_loadouts;
|
||||
// Presets whose load was refused only because the cosmetics catalog was not
|
||||
// ready yet, with the record the game asked us to fill. The game asks once
|
||||
// per level load and does not ask again, so the pump retries these once the
|
||||
// catalog is ready. Until one succeeds the preset stays in
|
||||
// `blocked_loadouts`, so a save cannot write over the outfit on disk.
|
||||
//
|
||||
// The loadout manager's array is recorded with it. A record is only written
|
||||
// to when that array is still the same one, bounds and all: inside the
|
||||
// array and on its stride says a record is there, not that it is still
|
||||
// this preset's, and a level load can rebuild the array under us. Writing
|
||||
// one preset's outfit into another's slot would lose a saved character,
|
||||
// which is worse than not applying it at all.
|
||||
struct PendingLoad {
|
||||
void* destination{};
|
||||
std::uintptr_t manager{}, begin{}, end{};
|
||||
};
|
||||
std::map<std::string, PendingLoad, std::less<>> pending_loads;
|
||||
// Saved outfit items that were not installed when their preset loaded (a
|
||||
// disabled or removed costume mod). The slot shows its default this session;
|
||||
// saves keep the original item unless the player changes that slot.
|
||||
// saves keep the original item unless the player changes that slot. Also
|
||||
// saved slots the template did not have then (a mod that adds slots,
|
||||
// switched off): those have no slot to show in, and saves carry them along.
|
||||
struct HeldSlot { std::size_t recipe{}; profile::CosmeticSlot saved; std::string fallback; };
|
||||
std::map<std::string, std::vector<HeldSlot>, std::less<>> held_slots;
|
||||
std::set<std::string> diagnostics;
|
||||
@@ -122,11 +110,11 @@ void initialize_starter_from_expression(std::uintptr_t vm, std::uint32_t pc) noe
|
||||
bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadout& saved,
|
||||
const profile::CosmeticLoadout& defaults);
|
||||
|
||||
// The game calls this with a temporary of its own (a slot's default recipes) and
|
||||
// builds the slot from what is left in it: the saved outfit when this returns
|
||||
// true, the standard skater when it returns false. It asks once, when the slot
|
||||
// is first needed after a level load, and there is no record to fill in later.
|
||||
bool load_cosmetic_hook(const void* wrapper, void* destination);
|
||||
// Applies the outfits whose load was refused for a catalog that was not ready.
|
||||
// Call from the customization pump, holding native_mutex, once
|
||||
// refresh_cosmetic_catalog() is true.
|
||||
void retry_pending_cosmetic_loads();
|
||||
|
||||
void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ struct State {
|
||||
std::vector<overlay::Nametag> tags;
|
||||
Clock::time_point published;
|
||||
bool hidden{}; // the game hides its own nametags (menus, hidden UI; game_ui_state.h)
|
||||
bool show_names{true}, show_bubbles{}, dots{true};
|
||||
float bubble_distance{40.f}, name_distance{120.f};
|
||||
};
|
||||
State &state() {
|
||||
static auto *value = new State;
|
||||
@@ -34,7 +36,8 @@ State &state() {
|
||||
} // namespace
|
||||
|
||||
void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> players,
|
||||
std::optional<std::array<float, 3>> local) noexcept {
|
||||
std::optional<std::array<float, 3>> local, bool show_names,
|
||||
bool show_bubbles, float bubble_distance, float name_distance, bool dots) noexcept {
|
||||
auto &s = state();
|
||||
if (!s.enabled.load(std::memory_order_acquire)) return;
|
||||
s.base.store(base, std::memory_order_release);
|
||||
@@ -54,6 +57,11 @@ void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> pla
|
||||
tag.color = player.color;
|
||||
tag.tag = std::move(player.tag);
|
||||
tag.talking = player.talking;
|
||||
tag.bubbles.reserve(player.bubbles.size());
|
||||
for (auto &line : player.bubbles)
|
||||
tag.bubbles.push_back({std::move(line.text), std::move(line.raw), line.appear, line.fade});
|
||||
tag.self = player.self;
|
||||
tag.nameless = player.nameless;
|
||||
if (local) {
|
||||
const float dx = player.head[0] - (*local)[0], dy = player.head[1] - (*local)[1], dz = player.head[2] - (*local)[2];
|
||||
tag.distance = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
@@ -62,6 +70,11 @@ void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> pla
|
||||
}
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.hidden = hidden;
|
||||
s.show_names = show_names;
|
||||
s.show_bubbles = show_bubbles;
|
||||
s.bubble_distance = bubble_distance;
|
||||
s.name_distance = name_distance;
|
||||
s.dots = dots;
|
||||
if (!view) {
|
||||
s.tags.clear();
|
||||
return;
|
||||
@@ -113,6 +126,11 @@ overlay::Nametags custom_nametags() {
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (s.hidden || s.tags.empty() || Clock::now() - s.published > std::chrono::milliseconds(250)) return {};
|
||||
result.tags = s.tags;
|
||||
result.show_names = s.show_names;
|
||||
result.show_bubbles = s.show_bubbles;
|
||||
result.bubble_distance = s.bubble_distance;
|
||||
result.name_distance = s.name_distance;
|
||||
result.dots = s.dots;
|
||||
view = s.view;
|
||||
}
|
||||
read_live_view(s.base.load(std::memory_order_acquire), view);
|
||||
|
||||
@@ -10,24 +10,55 @@
|
||||
// ReSkate's own nametags (drawn by the overlay, Extension/UI/Overlay/nametag_overlay.cpp)
|
||||
// in place of the game's nametag and compass arrows; the pause map's player icons stay.
|
||||
namespace dingosdk::multiplayer {
|
||||
// One line in a player's bubble stack: the text (masked when the chat filter is on), the line
|
||||
// as sent when the filter changed it (so emote names survive), how far its pop-in has come
|
||||
// (0 just arrived, 1 settled) and how opaque it still is (1, down to 0 as it goes).
|
||||
struct NametagBubble {
|
||||
std::string text, raw;
|
||||
float appear{1}, fade{1};
|
||||
};
|
||||
struct NametagPlayer {
|
||||
std::array<float, 3> head{}; // world position above the skater
|
||||
std::string name;
|
||||
std::uint32_t color{0xffffffffU}; // IM_COL32 layout
|
||||
std::string tag; // role badge before the name, as in chat ("" for none)
|
||||
bool talking{};
|
||||
// The player's recent chat lines to show as bubbles above the head, oldest first. Empty
|
||||
// draws no bubbles.
|
||||
std::vector<NametagBubble> bubbles;
|
||||
// The local player's own tag: only ever draws bubbles, never a name or dot.
|
||||
bool self{};
|
||||
// Another player whose name is not shown (friends-only nametags): bubbles only, like `self`.
|
||||
bool nameless{};
|
||||
};
|
||||
// Role colours; friends are the viewer's own Steam friends.
|
||||
inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 0xffff78b4U, // purple
|
||||
nametag_admin = 0xff3dc8ffU, // gold
|
||||
nametag_creator = 0xff6464ffU, // red
|
||||
nametag_homie = 0xff7ae2ffU, // gold
|
||||
nametag_centrix = 0xffff7b1fU, // blue (#1F7BFF)
|
||||
nametag_staff = 0xff71cc2eU, // green (#2ECC71)
|
||||
nametag_admin = 0xffc674ffU, // pink
|
||||
nametag_host = 0xffffc85eU, // blue
|
||||
nametag_friend = 0xff8ae07bU; // green
|
||||
inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU; // #6E20FF, IM_COL32 layout
|
||||
nametag_friend = 0xff8ae07bU, // green
|
||||
nametag_server = 0xffff5c8eU, // violet (#8E5CFF)
|
||||
nametag_server_text = 0xffffc8d9U; // lavender (#D9C8FF), its lines' text
|
||||
// Developers, staff, content creators, homies and Centrix shimmer from these to their role
|
||||
// colour (Centrix between white and its blue).
|
||||
inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF, IM_COL32 layout
|
||||
nametag_creator_start = 0xff2e10c8U, // #C8102E
|
||||
nametag_homie_start = 0xff008ae0U, // #E08A00
|
||||
nametag_centrix_start = 0xffffffffU, // white
|
||||
nametag_staff_start = 0xff3a6b0bU; // #0B6B3A
|
||||
|
||||
// Client thread, once per rendered frame: everyone to label and the local skater's position
|
||||
// (for distances). Also reads whether the game is hiding its own nametags right now.
|
||||
// `show_names` draws the name, distance and role badge; `show_bubbles` draws chat bubbles
|
||||
// within `bubble_distance` metres. Names show within `name_distance` metres; past it, and for
|
||||
// players off screen, a dot when `dots` is on.
|
||||
void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> players,
|
||||
std::optional<std::array<float, 3>> local) noexcept;
|
||||
std::optional<std::array<float, 3>> local, bool show_names,
|
||||
bool show_bubbles, float bubble_distance, float name_distance = 120.f,
|
||||
bool dots = true) noexcept;
|
||||
void set_custom_nametags_enabled(bool enabled) noexcept;
|
||||
bool custom_nametags_enabled() noexcept;
|
||||
// The overlay's feed (any thread): nothing while off, stale, or while the game hides nametags.
|
||||
|
||||
@@ -388,7 +388,7 @@ void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigne
|
||||
if (!roster[i].id) continue;
|
||||
auto &record = next->records[i]; record.player = roster[i];
|
||||
record.player_id = 0x7e000000U + static_cast<std::uint32_t>(i);
|
||||
// The game's colour list is sized for its eight-member parties: a bigger lobby party repeats them.
|
||||
// The game's colour list is sized for its eight-member parties: a bigger one repeats them.
|
||||
record.colour = roster[i].member ? colours++ % static_cast<std::int32_t>(authored_party_slots) : -1;
|
||||
record.friend_of = std::any_of(steam->friends.begin(), steam->friends.end(), [&](const auto &friend_info) {
|
||||
return friend_info.id == roster[i].id;
|
||||
|
||||
@@ -37,8 +37,8 @@ void spectate_party_member(std::uint64_t id) noexcept;
|
||||
// Returns only a record owned by this adapter, validated against this manager.
|
||||
std::uint64_t native_party_player_info(std::uintptr_t manager, std::size_t slot) noexcept;
|
||||
std::string native_party_status();
|
||||
// Whether players form their own parties (a dedicated server); a lobby is one fixed party, so
|
||||
// the game's party buttons are refused there.
|
||||
// Whether players can form parties: in a session, a lobby or a dedicated server. Outside one
|
||||
// the game's party buttons are refused.
|
||||
void set_native_party_changes(bool allowed) noexcept;
|
||||
// A party invite from `from` (a session player) as the game's own invite toast, whose Accept and
|
||||
// Decline come back through the request hooks. False when it could not be shown. Game thread.
|
||||
|
||||
@@ -303,7 +303,7 @@ void update_map_markers() {
|
||||
auto &marker = s.markers[slot];
|
||||
const auto record = published->slot_record(slot);
|
||||
const auto value = record ? target(record->player.id) : std::optional<Target>{};
|
||||
// Party markers are for the local player's party (a lobby is one party); everyone
|
||||
// Party markers are for the local player's party; everyone
|
||||
// else shows as the game's plain player dot (native_player_ui.cpp).
|
||||
if (!value || !available(*value) || !record->player.member) { remove(marker); continue; }
|
||||
if (marker.id != record->player.id || marker.epoch != record->player.epoch ||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// The game's own party buttons (Social menu, player cards, invites) end in a handful of request
|
||||
// natives that need a native Blaze game group, which ReSkate never has. While the SDK owns the
|
||||
// party, these hooks turn each request into a ReSkate party request (the session's "party"
|
||||
// command, which a dedicated server answers) and complete the button's callback themselves.
|
||||
// command, which whoever hosts answers) and complete the button's callback themselves.
|
||||
// Party queries are answered from the published roster (analysis/party-re/native-core.md).
|
||||
namespace dingosdk::multiplayer {
|
||||
using namespace native_party_detail;
|
||||
@@ -40,7 +40,7 @@ struct Requests {
|
||||
};
|
||||
Requests &requests() { static auto *value = new Requests; return *value; }
|
||||
std::atomic<bool> changes_allowed{};
|
||||
constexpr const char *lobby_party = "Everyone in a lobby is in one party with the host.";
|
||||
constexpr const char *lobby_party = "Parties need a multiplayer session.";
|
||||
std::uint64_t now_ms() {
|
||||
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
@@ -333,7 +333,7 @@ bool post_event(std::uintptr_t type, const void *payload) {
|
||||
}
|
||||
void post_party_changes(const Published *previous, const Published &next) {
|
||||
if (!requests().installed.load(std::memory_order_acquire)) return;
|
||||
// The local party as the game shows it (a hidden lobby party is none).
|
||||
// The local party as the game shows it.
|
||||
const auto members = [](const Published *p) {
|
||||
std::map<std::uint64_t, bool> result; // id -> leads
|
||||
if (!p) return result;
|
||||
|
||||
@@ -43,7 +43,8 @@ WireUpdate DeltaSender::build(const Packet &p, std::span<const std::uint8_t> raw
|
||||
}
|
||||
const auto found = bases_.find({p.source, p.kind});
|
||||
if (found == bases_.end() || found->second.epoch != p.epoch || found->second.world != p.world || p.time_us < found->second.time ||
|
||||
(p.kind != PacketKind::cosmetics && p.time_us - found->second.time >= 2000000)) {
|
||||
(p.kind != PacketKind::cosmetics &&
|
||||
(aligned_ ? p.time_us / refresh_us_ != found->second.time / refresh_us_ : p.time_us - found->second.time >= refresh_us_))) {
|
||||
if (wire.size() + 4 > max_packet)
|
||||
return out;
|
||||
out.bytes.assign(wire.begin(), wire.end());
|
||||
|
||||
@@ -32,6 +32,8 @@ class DeltaSender {
|
||||
};
|
||||
std::map<StreamKey, Base> bases_;
|
||||
std::uint64_t clock_{};
|
||||
std::uint64_t refresh_us_ = 2000000;
|
||||
bool aligned_{};
|
||||
WireUpdate build(const Packet &, std::span<const std::uint8_t> raw, std::span<const std::uint8_t> wire,
|
||||
DeltaCache *cache) const;
|
||||
|
||||
@@ -44,6 +46,18 @@ class DeltaSender {
|
||||
WireUpdate prepare(const Packet &, std::span<const std::uint8_t> raw, std::span<const std::uint8_t> wire,
|
||||
DeltaCache &cache) const;
|
||||
void sent(const Packet &, WireUpdate &&);
|
||||
// Drops a stream's reference: the next packet of it goes as a whole state.
|
||||
void forget(std::uint64_t source, PacketKind kind) { bases_.erase(StreamKey{source, kind}); }
|
||||
// How old a pose or sound reference may get before a whole state replaces it. Each one
|
||||
// is sent reliably, so Steam resends it until it arrives: a dedicated server, with a
|
||||
// stream for every pair of players, keeps them rarer than the two seconds a game does.
|
||||
// `aligned`: on the source's own clock, at each multiple of the time, instead of that
|
||||
// long after this recipient's last one. Every recipient of a source then takes the same
|
||||
// packet as its reference, and one patch serves them all (DeltaCache).
|
||||
void set_refresh(std::uint64_t microseconds, bool aligned = false) {
|
||||
refresh_us_ = microseconds;
|
||||
aligned_ = aligned;
|
||||
}
|
||||
};
|
||||
class DeltaReceiver {
|
||||
struct Base {
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
#pragma once
|
||||
#include "pose_codec.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// Poses from a dedicated server to a player: several players' poses in one message, each as its
|
||||
// differences (pose_codec.h) from a pose of that player the receiver has said it holds.
|
||||
//
|
||||
// A message is a header and entries. A whole pose opens a stream: it names the player and gives
|
||||
// the stream a number, and every later entry is that number, which earlier pose it builds on, and
|
||||
// the differences. The receiver answers with which messages it could read in full (an ack: the
|
||||
// newest and a bit for each of the 64 before it), and the server builds only on poses in messages
|
||||
// that were acked, or on the whole pose it sent reliably. Messages are kept to one packet, so a
|
||||
// lost packet loses a handful of poses, each of which the next one makes good.
|
||||
namespace dingosdk::multiplayer::pose_batch {
|
||||
inline constexpr std::array<std::uint8_t, 4> batch_magic{'R', 'M', 'Q', '1'}, ack_magic{'R', 'M', 'A', '1'};
|
||||
inline constexpr std::size_t header_size = 16, ack_size = 16;
|
||||
// Entries are added to a message while it stays under this, which with Steam's own framing fits a packet.
|
||||
inline constexpr std::size_t message_room = 1100;
|
||||
// How many poses a receiver keeps of each stream to build on, and how many a server may send
|
||||
// past the newest one acked before it sends the stream whole again.
|
||||
inline constexpr std::size_t receiver_keeps = 32, sender_runs = 24;
|
||||
|
||||
inline bool is_batch(std::span<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() >= header_size && std::equal(batch_magic.begin(), batch_magic.end(), bytes.begin());
|
||||
}
|
||||
inline bool is_ack(std::span<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() == ack_size && std::equal(ack_magic.begin(), ack_magic.end(), bytes.begin());
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
inline void put(std::vector<std::uint8_t> &out, std::uint64_t value, unsigned bytes) {
|
||||
for (unsigned i = 0; i < bytes; ++i) out.push_back(static_cast<std::uint8_t>(value >> (8 * i)));
|
||||
}
|
||||
inline void varint(std::vector<std::uint8_t> &out, std::uint64_t value) {
|
||||
while (value >= 0x80) {
|
||||
out.push_back(static_cast<std::uint8_t>(value | 0x80));
|
||||
value >>= 7;
|
||||
}
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
}
|
||||
struct Reader {
|
||||
std::span<const std::uint8_t> bytes;
|
||||
std::size_t at{};
|
||||
std::uint64_t get(unsigned count) {
|
||||
if (at > bytes.size() || count > bytes.size() - at) throw std::runtime_error("Truncated pose batch");
|
||||
std::uint64_t value{};
|
||||
for (unsigned i = 0; i < count; ++i) value |= std::uint64_t{bytes[at++]} << (8 * i);
|
||||
return value;
|
||||
}
|
||||
std::uint64_t varint() {
|
||||
std::uint64_t value{};
|
||||
for (unsigned shift = 0; shift < 64; shift += 7) {
|
||||
const auto byte = get(1);
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (!(byte & 0x80)) return value;
|
||||
}
|
||||
throw std::runtime_error("Invalid pose batch number");
|
||||
}
|
||||
std::span<const std::uint8_t> take(std::size_t count) {
|
||||
if (at > bytes.size() || count > bytes.size() - at) throw std::runtime_error("Truncated pose batch");
|
||||
const auto part = bytes.subspan(at, count);
|
||||
at += count;
|
||||
return part;
|
||||
}
|
||||
};
|
||||
constexpr std::uint64_t zigzag(std::int64_t value) noexcept {
|
||||
return (static_cast<std::uint64_t>(value) << 1) ^ static_cast<std::uint64_t>(value >> 63);
|
||||
}
|
||||
constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { return static_cast<std::int64_t>(value >> 1) ^ -static_cast<std::int64_t>(value & 1); }
|
||||
} // namespace detail
|
||||
|
||||
// How often the pose's player is being sent, which the receiver plays it back by: at the
|
||||
// session's own rate, or every 100 or 200 ms.
|
||||
enum class Rate : std::uint8_t { full, half, low };
|
||||
|
||||
struct Entry {
|
||||
std::uint16_t stream{};
|
||||
bool whole{}, collision{};
|
||||
Rate rate{Rate::full};
|
||||
std::uint64_t source{}, epoch{}; // whole only: who the stream is
|
||||
std::uint32_t sequence{}; // whole only: the pose's own number
|
||||
std::uint16_t reference{}; // difference only: the low bits of the number of the pose it builds on
|
||||
std::uint32_t ahead{}; // difference only: how far past that pose's number its own is (1 or more)
|
||||
std::uint64_t time_us{}; // whole: the pose's time. Difference: left 0, see time_step
|
||||
std::int64_t time_step{}; // difference only: its time less its reference's
|
||||
std::span<const std::uint8_t> pose; // pose_codec bytes
|
||||
};
|
||||
|
||||
// The first 16 bytes: which message this is, and the world and map it belongs to (their low bits:
|
||||
// enough to tell a message from before a map change).
|
||||
inline std::vector<std::uint8_t> begin(std::uint32_t number, std::uint64_t world, std::uint64_t map) {
|
||||
std::vector<std::uint8_t> out(batch_magic.begin(), batch_magic.end());
|
||||
out.reserve(message_room + 64);
|
||||
detail::put(out, number, 4);
|
||||
detail::put(out, world & 0xffffffff, 4);
|
||||
detail::put(out, map & 0xffffffff, 4);
|
||||
return out;
|
||||
}
|
||||
inline void add(std::vector<std::uint8_t> &out, const Entry &entry) {
|
||||
detail::varint(out, entry.stream);
|
||||
out.push_back(static_cast<std::uint8_t>((entry.whole ? 1U : 0U) | (entry.collision ? 2U : 0U) | (static_cast<unsigned>(entry.rate) << 2)));
|
||||
if (entry.whole) {
|
||||
detail::put(out, entry.source, 8);
|
||||
detail::put(out, entry.epoch, 8);
|
||||
detail::put(out, entry.sequence, 4);
|
||||
detail::put(out, entry.time_us, 8);
|
||||
} else {
|
||||
detail::put(out, entry.reference, 2);
|
||||
detail::varint(out, entry.ahead);
|
||||
detail::varint(out, detail::zigzag(entry.time_step));
|
||||
}
|
||||
detail::varint(out, entry.pose.size());
|
||||
out.insert(out.end(), entry.pose.begin(), entry.pose.end());
|
||||
}
|
||||
// The room an entry takes beyond its pose bytes, at most.
|
||||
inline constexpr std::size_t entry_room = 3 + 1 + 28 + 3;
|
||||
|
||||
struct Batch {
|
||||
std::uint32_t number{}, world{}, map{};
|
||||
std::vector<Entry> entries; // their pose bytes point into the message read
|
||||
};
|
||||
// A message's header and entries, or nothing when it is not one or is cut short.
|
||||
inline std::optional<Batch> read(std::span<const std::uint8_t> bytes) noexcept {
|
||||
try {
|
||||
if (!is_batch(bytes)) return {};
|
||||
detail::Reader r{bytes, 4};
|
||||
Batch batch;
|
||||
batch.number = static_cast<std::uint32_t>(r.get(4));
|
||||
batch.world = static_cast<std::uint32_t>(r.get(4));
|
||||
batch.map = static_cast<std::uint32_t>(r.get(4));
|
||||
while (r.at < bytes.size()) {
|
||||
Entry entry;
|
||||
const auto stream = r.varint();
|
||||
if (stream > 0xffff) return {};
|
||||
entry.stream = static_cast<std::uint16_t>(stream);
|
||||
const auto flags = r.get(1);
|
||||
if (flags & ~0xfULL || ((flags >> 2) & 3) > 2) return {};
|
||||
entry.whole = flags & 1;
|
||||
entry.collision = flags & 2;
|
||||
entry.rate = static_cast<Rate>((flags >> 2) & 3);
|
||||
if (entry.whole) {
|
||||
entry.source = r.get(8);
|
||||
entry.epoch = r.get(8);
|
||||
entry.sequence = static_cast<std::uint32_t>(r.get(4));
|
||||
entry.time_us = r.get(8);
|
||||
} else {
|
||||
entry.reference = static_cast<std::uint16_t>(r.get(2));
|
||||
const auto ahead = r.varint();
|
||||
if (!ahead || ahead > 0xffffff) return {};
|
||||
entry.ahead = static_cast<std::uint32_t>(ahead);
|
||||
entry.time_step = detail::unzigzag(r.varint());
|
||||
}
|
||||
const auto length = r.varint();
|
||||
if (length > bytes.size()) return {};
|
||||
entry.pose = r.take(static_cast<std::size_t>(length));
|
||||
batch.entries.push_back(entry);
|
||||
if (batch.entries.size() > 512) return {};
|
||||
}
|
||||
return batch;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Which messages a receiver read in full: the newest, and a bit for each of the 64 before it
|
||||
// (bit 0 the one just before).
|
||||
struct Ack {
|
||||
std::uint32_t newest{};
|
||||
std::uint64_t earlier{};
|
||||
bool any{};
|
||||
void note(std::uint32_t number) noexcept {
|
||||
if (!any) {
|
||||
any = true;
|
||||
newest = number;
|
||||
earlier = 0;
|
||||
} else if (number > newest) {
|
||||
const auto step = number - newest;
|
||||
earlier = step >= 64 ? 0 : earlier << step;
|
||||
if (step <= 64) earlier |= std::uint64_t{1} << (step - 1); // the one that was newest
|
||||
newest = number;
|
||||
} else if (number < newest && newest - number <= 64) {
|
||||
earlier |= std::uint64_t{1} << (newest - number - 1);
|
||||
}
|
||||
}
|
||||
bool holds(std::uint32_t number) const noexcept {
|
||||
return any && (number == newest || (number < newest && newest - number <= 64 && (earlier >> (newest - number - 1)) & 1));
|
||||
}
|
||||
std::array<std::uint8_t, ack_size> bytes() const noexcept {
|
||||
std::array<std::uint8_t, ack_size> out{};
|
||||
std::copy(ack_magic.begin(), ack_magic.end(), out.begin());
|
||||
for (unsigned i = 0; i < 4; ++i) out[4 + i] = static_cast<std::uint8_t>(newest >> (8 * i));
|
||||
for (unsigned i = 0; i < 8; ++i) out[8 + i] = static_cast<std::uint8_t>(earlier >> (8 * i));
|
||||
return out;
|
||||
}
|
||||
static std::optional<Ack> read(std::span<const std::uint8_t> bytes) noexcept {
|
||||
if (!is_ack(bytes)) return {};
|
||||
Ack ack;
|
||||
ack.any = true;
|
||||
for (unsigned i = 0; i < 4; ++i) ack.newest |= std::uint32_t{bytes[4 + i]} << (8 * i);
|
||||
for (unsigned i = 0; i < 8; ++i) ack.earlier |= std::uint64_t{bytes[8 + i]} << (8 * i);
|
||||
return ack;
|
||||
}
|
||||
};
|
||||
|
||||
// A receiver's side of one stream: who it is, and the last poses of theirs it rebuilt.
|
||||
struct Stream {
|
||||
std::uint64_t source{}, epoch{};
|
||||
struct Held {
|
||||
std::uint32_t sequence{};
|
||||
std::uint64_t time_us{};
|
||||
pose_codec::QuantPose pose;
|
||||
};
|
||||
std::deque<Held> held;
|
||||
const Held *find_low(std::uint16_t low) const noexcept {
|
||||
for (auto it = held.rbegin(); it != held.rend(); ++it)
|
||||
if (static_cast<std::uint16_t>(it->sequence) == low) return &*it;
|
||||
return nullptr;
|
||||
}
|
||||
bool holds(std::uint32_t sequence) const noexcept {
|
||||
for (const auto &pose : held)
|
||||
if (pose.sequence == sequence) return true;
|
||||
return false;
|
||||
}
|
||||
void keep(Held pose) {
|
||||
held.push_back(std::move(pose));
|
||||
while (held.size() > receiver_keeps) held.pop_front();
|
||||
}
|
||||
};
|
||||
// What one entry rebuilds to on the receiver, or nothing when it cannot be (a stream it was never
|
||||
// told of, a reference it does not hold, bytes that are not a pose). `streams` is updated: a whole
|
||||
// pose opens or reopens its stream, and every rebuilt pose is kept to build on.
|
||||
struct Rebuilt {
|
||||
std::uint64_t source{}, epoch{}, time_us{};
|
||||
std::uint32_t sequence{};
|
||||
bool collision{};
|
||||
Rate rate{Rate::full};
|
||||
pose_codec::QuantPose pose;
|
||||
bool repeat{}; // this stream's pose of that number was already held: nothing new
|
||||
};
|
||||
template <typename Streams> std::optional<Rebuilt> rebuild(Streams &streams, const Entry &entry) {
|
||||
Rebuilt out;
|
||||
out.collision = entry.collision;
|
||||
out.rate = entry.rate;
|
||||
if (entry.whole) {
|
||||
if (!pose_codec::whole(entry.pose)) return {};
|
||||
auto pose = pose_codec::decode(entry.pose, nullptr);
|
||||
if (!pose) return {};
|
||||
auto &stream = streams[entry.stream];
|
||||
if (stream.source != entry.source || stream.epoch != entry.epoch) stream = {entry.source, entry.epoch, {}};
|
||||
out.source = entry.source;
|
||||
out.epoch = entry.epoch;
|
||||
out.sequence = entry.sequence;
|
||||
out.time_us = entry.time_us;
|
||||
out.repeat = stream.holds(entry.sequence);
|
||||
out.pose = std::move(*pose);
|
||||
if (!out.repeat) stream.keep({out.sequence, out.time_us, out.pose});
|
||||
return out;
|
||||
}
|
||||
const auto found = streams.find(entry.stream);
|
||||
if (found == streams.end() || pose_codec::whole(entry.pose)) return {};
|
||||
auto &stream = found->second;
|
||||
const auto *reference = stream.find_low(entry.reference);
|
||||
if (!reference) return {};
|
||||
auto pose = pose_codec::decode(entry.pose, &reference->pose);
|
||||
if (!pose) return {};
|
||||
out.source = stream.source;
|
||||
out.epoch = stream.epoch;
|
||||
out.sequence = reference->sequence + entry.ahead;
|
||||
out.time_us = static_cast<std::uint64_t>(static_cast<std::int64_t>(reference->time_us) + entry.time_step);
|
||||
out.repeat = stream.holds(out.sequence);
|
||||
out.pose = std::move(*pose);
|
||||
if (!out.repeat) stream.keep({out.sequence, out.time_us, out.pose});
|
||||
return out;
|
||||
}
|
||||
|
||||
// A server's side of what one player is sent: for each other player a stream, the newest pose of
|
||||
// it the receiver acked, and the messages sent and not yet acked. Poses are handed to add() as
|
||||
// they fall due; it packs them into messages and gives each to `emit(bytes, reliable)`, which
|
||||
// answers whether it went out. `find(sequence)` gives a kept pose of the player being added, or
|
||||
// nothing when it is no longer kept.
|
||||
struct KeptView {
|
||||
std::uint32_t sequence{};
|
||||
std::uint64_t time_us{};
|
||||
const pose_codec::QuantPose *pose{};
|
||||
};
|
||||
class Sender {
|
||||
public:
|
||||
// What add() did with a pose, for counting.
|
||||
enum class Did : std::uint8_t { nothing, difference, whole };
|
||||
struct Added {
|
||||
Did did{Did::nothing};
|
||||
std::size_t bytes{};
|
||||
};
|
||||
void begin(std::uint64_t world, std::uint64_t map) {
|
||||
world_ = world;
|
||||
map_ = map;
|
||||
message_.clear();
|
||||
batch_ = {};
|
||||
}
|
||||
template <typename Find, typename Emit>
|
||||
Added add(std::uint64_t source, std::uint64_t epoch, const KeptView &pose, Find &&find, bool hold_fingers, bool collision, Rate rate,
|
||||
std::uint64_t now, Emit &&emit) {
|
||||
using pose_codec::Fingers;
|
||||
auto &out = streams_[source];
|
||||
if (!out.stream || out.epoch != epoch) {
|
||||
out = {};
|
||||
out.epoch = epoch;
|
||||
out.stream = next_stream_++;
|
||||
if (!next_stream_) next_stream_ = 1;
|
||||
}
|
||||
// The pose to build on: the newest they acked, else the whole one sent reliably (it will
|
||||
// arrive; a difference that overtakes it is only dropped). Neither when too many have
|
||||
// gone since for the receiver to still hold it, or it is no longer kept here.
|
||||
std::optional<KeptView> reference;
|
||||
if (out.acked && out.sends - out.acked_send <= sender_runs) reference = find(out.acked_sequence);
|
||||
if (!reference && out.whole_sent && out.sends - out.whole_send <= sender_runs) reference = find(out.whole_sequence);
|
||||
if (reference && pose.sequence <= reference->sequence) return {}; // nothing newer than they hold
|
||||
Entry entry;
|
||||
entry.stream = out.stream;
|
||||
entry.collision = collision;
|
||||
entry.rate = rate;
|
||||
std::vector<std::uint8_t> bytes;
|
||||
bool fingers{};
|
||||
if (reference) {
|
||||
// Fingers: not sent to a player too far to see them; and after that, sent whole until
|
||||
// an ack shows the receiver has them again, since its copy of the reference has old
|
||||
// ones in it.
|
||||
const auto mode = hold_fingers ? Fingers::held : out.fingers_stale ? Fingers::whole : Fingers::sent;
|
||||
bytes = pose_codec::encode_delta(*pose.pose, *reference->pose, mode);
|
||||
fingers = mode == Fingers::whole;
|
||||
if (bytes.empty()) reference.reset(); // another skeleton (a changed rig): whole
|
||||
}
|
||||
if (!reference) {
|
||||
// The whole pose, in a message of its own, reliably. Not again while the last is still
|
||||
// on its way: at most one a second for a stream.
|
||||
if (out.whole_sent && now - out.whole_at < 1000000) return {};
|
||||
flush(emit);
|
||||
open();
|
||||
bytes = pose_codec::encode_whole(*pose.pose);
|
||||
entry.whole = true;
|
||||
entry.source = source;
|
||||
entry.epoch = epoch;
|
||||
entry.sequence = pose.sequence;
|
||||
entry.time_us = pose.time_us;
|
||||
entry.pose = bytes;
|
||||
pose_batch::add(message_, entry);
|
||||
out.acked = false;
|
||||
out.whole_sent = true;
|
||||
out.whole_sequence = pose.sequence;
|
||||
out.whole_send = ++out.sends;
|
||||
out.whole_at = now;
|
||||
out.fingers_stale = false;
|
||||
batch_.poses.push_back({source, epoch, pose.sequence, out.sends, true});
|
||||
send(emit, true);
|
||||
return {Did::whole, bytes.size()};
|
||||
}
|
||||
entry.reference = static_cast<std::uint16_t>(reference->sequence);
|
||||
entry.ahead = pose.sequence - reference->sequence;
|
||||
entry.time_step = static_cast<std::int64_t>(pose.time_us) - static_cast<std::int64_t>(reference->time_us);
|
||||
entry.pose = bytes;
|
||||
if (message_.empty()) open();
|
||||
if (!batch_.poses.empty() && message_.size() + bytes.size() + entry_room > message_room) {
|
||||
send(emit, false);
|
||||
open();
|
||||
}
|
||||
pose_batch::add(message_, entry);
|
||||
if (hold_fingers) {
|
||||
out.fingers_stale = true;
|
||||
out.last_held_batch = batch_.number;
|
||||
}
|
||||
batch_.poses.push_back({source, epoch, pose.sequence, ++out.sends, fingers});
|
||||
return {Did::difference, bytes.size()};
|
||||
}
|
||||
// Sends what add() has packed so far.
|
||||
template <typename Emit> void flush(Emit &&emit) { send(emit, false); }
|
||||
// The receiver said which messages it read in full: the poses in those are ones it holds.
|
||||
void ack(const Ack &ack) {
|
||||
for (auto it = sent_.begin(); it != sent_.end();) {
|
||||
if (!ack.holds(it->number)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
for (const auto &pose : it->poses) {
|
||||
const auto found = streams_.find(pose.source);
|
||||
if (found == streams_.end() || found->second.epoch != pose.epoch) continue;
|
||||
auto &out = found->second;
|
||||
if (!out.acked || pose.sequence > out.acked_sequence) {
|
||||
out.acked = true;
|
||||
out.acked_sequence = pose.sequence;
|
||||
out.acked_send = pose.send;
|
||||
}
|
||||
// (Not when a pose without fingers went out after this one.)
|
||||
if (pose.fingers && it->number > out.last_held_batch) out.fingers_stale = false;
|
||||
}
|
||||
it = sent_.erase(it);
|
||||
}
|
||||
while (!sent_.empty() && sent_.front().number + 128 < ack.newest) sent_.pop_front();
|
||||
}
|
||||
// A new world: every stream starts over. The messages' numbers go on, so the receiver's
|
||||
// acks need no starting over with them.
|
||||
void restart() {
|
||||
streams_.clear();
|
||||
sent_.clear();
|
||||
message_.clear();
|
||||
batch_ = {};
|
||||
}
|
||||
// The player left: their stream is nobody's.
|
||||
void forget(std::uint64_t source) { streams_.erase(source); }
|
||||
std::size_t unacked() const noexcept { return sent_.size(); }
|
||||
|
||||
private:
|
||||
struct Out {
|
||||
std::uint64_t epoch{};
|
||||
std::uint16_t stream{};
|
||||
bool acked{}, whole_sent{}, fingers_stale{};
|
||||
std::uint32_t acked_sequence{}, whole_sequence{}, sends{}, acked_send{}, whole_send{}, last_held_batch{};
|
||||
std::uint64_t whole_at{};
|
||||
};
|
||||
struct SentPose {
|
||||
std::uint64_t source{}, epoch{};
|
||||
std::uint32_t sequence{}, send{};
|
||||
bool fingers{}; // it carried the fingers whole
|
||||
};
|
||||
struct SentBatch {
|
||||
std::uint32_t number{};
|
||||
std::vector<SentPose> poses;
|
||||
};
|
||||
void open() {
|
||||
batch_ = {next_batch_++, {}};
|
||||
message_ = pose_batch::begin(batch_.number, world_, map_);
|
||||
}
|
||||
// Unreliable messages are let go when the connection is behind (the next poses replace
|
||||
// them); nothing is then recorded as sent, so nothing is built on them either.
|
||||
template <typename Emit> void send(Emit &&emit, bool reliable) {
|
||||
if (batch_.poses.empty()) return;
|
||||
if (emit(std::span<const std::uint8_t>(message_), reliable)) {
|
||||
sent_.push_back(std::move(batch_));
|
||||
while (sent_.size() > 256) sent_.pop_front();
|
||||
}
|
||||
batch_ = {};
|
||||
message_.clear();
|
||||
}
|
||||
std::map<std::uint64_t, Out> streams_;
|
||||
std::deque<SentBatch> sent_;
|
||||
std::vector<std::uint8_t> message_;
|
||||
SentBatch batch_;
|
||||
std::uint64_t world_{}, map_{};
|
||||
std::uint16_t next_stream_ = 1;
|
||||
std::uint32_t next_batch_ = 1;
|
||||
};
|
||||
} // namespace dingosdk::multiplayer::pose_batch
|
||||
@@ -0,0 +1,361 @@
|
||||
#pragma once
|
||||
#include "protocol.h"
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// A pose as the differences from an earlier one, each in as few bits as it needs.
|
||||
//
|
||||
// Measured on a minute of recorded play (tools/research, pose-dump): of a skater's 413 bones 87
|
||||
// ever turn and about 70 turn in any one pose, by a degree or so; 40 of the 87 are fingers. The
|
||||
// older encoding sends each changed field whole (6 bytes a rotation) and leaves the rest to a
|
||||
// general compressor: 661 bytes a pose. Here a pose is first rounded to whole steps (QuantPose),
|
||||
// and what is sent is, for each bone that changed, the small integer it changed by: about 310
|
||||
// bytes against a pose 67 ms older, 215 without the fingers.
|
||||
//
|
||||
// Both ends work on the rounded integers, never on the floats, so a chain of differences cannot
|
||||
// drift: applying a difference to the reference gives exactly the sender's rounded pose.
|
||||
namespace dingosdk::multiplayer::pose_codec {
|
||||
// Rotations are rounded to 2^rotation_shift of the 16-bit form a compact pose packs them in:
|
||||
// 0.079 degrees. Positions to a millimetre.
|
||||
inline constexpr unsigned rotation_shift = 5;
|
||||
inline constexpr int rotation_bits = 16 - rotation_shift + 1; // a whole component, signed
|
||||
inline constexpr float rotation_scale = 46339.5358f; // as Writer::compact_transform
|
||||
inline constexpr std::int32_t position_limit = 1000000000; // mm: valid_transform's million metres
|
||||
|
||||
struct QuantBone {
|
||||
std::int16_t rotation[3]{}; // the three smaller components, in steps
|
||||
std::uint8_t largest{3}; // which component is left out (and is positive)
|
||||
bool scaled{};
|
||||
std::int32_t position[3]{}; // mm
|
||||
float scale[3]{1, 1, 1}; // sent whole, and only when it is not 1: mods aside it always is
|
||||
bool operator==(const QuantBone &) const = default;
|
||||
};
|
||||
// The root, the skater's bones, then the board's.
|
||||
struct QuantPose {
|
||||
std::uint16_t skater{}, board{};
|
||||
std::vector<QuantBone> bones;
|
||||
bool operator==(const QuantPose &) const = default;
|
||||
};
|
||||
// The skater's finger bones, as indices into QuantPose::bones (the root is 0): two runs of
|
||||
// twenty. Only in the skeleton this was measured on; any other bone count has none.
|
||||
inline constexpr std::size_t finger_skeleton = 395;
|
||||
inline constexpr bool finger_bone(const QuantPose &pose, std::size_t bone) noexcept {
|
||||
return pose.skater == finger_skeleton && ((bone >= 52 && bone < 72) || (bone >= 281 && bone < 301));
|
||||
}
|
||||
|
||||
inline QuantBone quantize(const Transform &t) noexcept {
|
||||
QuantBone bone;
|
||||
unsigned largest{};
|
||||
float norm{};
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
norm += t.rotation[i] * t.rotation[i];
|
||||
if (std::abs(t.rotation[i]) > std::abs(t.rotation[largest])) largest = i;
|
||||
}
|
||||
const float factor = norm > 0.f && std::isfinite(norm) ? (t.rotation[largest] < 0 ? -1.f : 1.f) * rotation_scale / std::sqrt(norm) : 0.f;
|
||||
const float step = static_cast<float>(1U << rotation_shift);
|
||||
const auto most = static_cast<long>(32767 >> rotation_shift);
|
||||
bone.largest = static_cast<std::uint8_t>(largest);
|
||||
unsigned out{};
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
if (i != largest) bone.rotation[out++] = static_cast<std::int16_t>(std::clamp(std::lround(t.rotation[i] * factor / step), -most, most));
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
const auto mm = std::isfinite(t.position[i]) ? std::llround(static_cast<double>(t.position[i]) * 1000.0) : 0LL;
|
||||
bone.position[i] = static_cast<std::int32_t>(std::clamp<long long>(mm, -position_limit, position_limit));
|
||||
}
|
||||
bone.scaled = t.scale != std::array<float, 3>{1, 1, 1};
|
||||
if (bone.scaled) std::copy(t.scale.begin(), t.scale.end(), bone.scale);
|
||||
return bone;
|
||||
}
|
||||
inline Transform restore(const QuantBone &bone) noexcept {
|
||||
Transform t;
|
||||
const float step = static_cast<float>(1U << rotation_shift) / rotation_scale;
|
||||
float sum{};
|
||||
unsigned in{};
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (i == bone.largest) continue;
|
||||
t.rotation[i] = static_cast<float>(bone.rotation[in++]) * step;
|
||||
sum += t.rotation[i] * t.rotation[i];
|
||||
}
|
||||
t.rotation[bone.largest & 3] = std::sqrt(std::max(0.f, 1.f - sum));
|
||||
for (unsigned i = 0; i < 3; ++i) t.position[i] = static_cast<float>(static_cast<double>(bone.position[i]) / 1000.0);
|
||||
if (bone.scaled) std::copy(bone.scale, bone.scale + 3, t.scale.begin());
|
||||
return t;
|
||||
}
|
||||
inline QuantPose quantize(const Pose &pose) {
|
||||
QuantPose out;
|
||||
out.skater = static_cast<std::uint16_t>(pose.skater.size());
|
||||
out.board = static_cast<std::uint16_t>(pose.board.size());
|
||||
out.bones.reserve(1 + pose.skater.size() + pose.board.size());
|
||||
out.bones.push_back(quantize(pose.root));
|
||||
for (const auto &t : pose.skater) out.bones.push_back(quantize(t));
|
||||
for (const auto &t : pose.board) out.bones.push_back(quantize(t));
|
||||
return out;
|
||||
}
|
||||
inline Pose restore(const QuantPose &pose) {
|
||||
Pose out;
|
||||
if (pose.bones.size() != std::size_t{1} + pose.skater + pose.board) return out;
|
||||
out.root = restore(pose.bones[0]);
|
||||
out.skater.reserve(pose.skater);
|
||||
out.board.reserve(pose.board);
|
||||
for (std::size_t i = 0; i < pose.skater; ++i) out.skater.push_back(restore(pose.bones[1 + i]));
|
||||
for (std::size_t i = 0; i < pose.board; ++i) out.board.push_back(restore(pose.bones[1 + pose.skater + i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
struct BitWriter {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint64_t hold{};
|
||||
unsigned held{};
|
||||
void put(std::uint64_t value, unsigned bits) { // the low `bits` of value, at most 32
|
||||
hold |= (value & ((std::uint64_t{1} << bits) - 1)) << held;
|
||||
held += bits;
|
||||
while (held >= 8) {
|
||||
bytes.push_back(static_cast<std::uint8_t>(hold));
|
||||
hold >>= 8;
|
||||
held -= 8;
|
||||
}
|
||||
}
|
||||
// A count of 1 or more, shorter the smaller it is (Elias gamma): bones that changed come in
|
||||
// runs, and the step from one to the next is nearly always 1, which is a single bit.
|
||||
void gap(std::uint32_t value) {
|
||||
const auto width = static_cast<unsigned>(std::bit_width(value));
|
||||
put(0, width - 1);
|
||||
put(1, 1);
|
||||
put(value, width - 1);
|
||||
}
|
||||
std::vector<std::uint8_t> finish() {
|
||||
if (held) bytes.push_back(static_cast<std::uint8_t>(hold));
|
||||
hold = held = 0;
|
||||
return std::move(bytes);
|
||||
}
|
||||
};
|
||||
struct BitReader {
|
||||
std::span<const std::uint8_t> bytes;
|
||||
std::size_t at{};
|
||||
std::uint64_t hold{};
|
||||
unsigned held{};
|
||||
std::uint64_t get(unsigned bits) {
|
||||
while (held < bits) {
|
||||
if (at >= bytes.size()) throw std::runtime_error("Truncated pose");
|
||||
hold |= std::uint64_t{bytes[at++]} << held;
|
||||
held += 8;
|
||||
}
|
||||
const auto value = hold & ((std::uint64_t{1} << bits) - 1);
|
||||
hold >>= bits;
|
||||
held -= bits;
|
||||
return value;
|
||||
}
|
||||
std::uint32_t gap() {
|
||||
unsigned width{1};
|
||||
while (!get(1))
|
||||
if (++width > 32) throw std::runtime_error("Invalid pose gap");
|
||||
return static_cast<std::uint32_t>((std::uint64_t{1} << (width - 1)) | get(width - 1));
|
||||
}
|
||||
};
|
||||
constexpr std::uint32_t zigzag(std::int64_t value) noexcept {
|
||||
return static_cast<std::uint32_t>(value >= 0 ? static_cast<std::uint64_t>(value) * 2 : static_cast<std::uint64_t>(-value) * 2 - 1);
|
||||
}
|
||||
constexpr std::int64_t unzigzag(std::uint64_t value) noexcept {
|
||||
return (value & 1) ? -static_cast<std::int64_t>((value + 1) / 2) : static_cast<std::int64_t>(value / 2);
|
||||
}
|
||||
// A signed number as its length (a fixed few bits) and then that many bits.
|
||||
inline void small(BitWriter &w, std::int64_t value, unsigned length_bits) {
|
||||
const auto coded = zigzag(value);
|
||||
const auto width = static_cast<unsigned>(std::bit_width(coded));
|
||||
w.put(width, length_bits);
|
||||
if (width > 32 - 1) {
|
||||
w.put(coded & 0xffff, 16);
|
||||
w.put(coded >> 16, width - 16);
|
||||
} else {
|
||||
w.put(coded, width);
|
||||
}
|
||||
}
|
||||
inline std::int64_t small(BitReader &r, unsigned length_bits) {
|
||||
const auto width = static_cast<unsigned>(r.get(length_bits));
|
||||
if (width > 32) throw std::runtime_error("Invalid pose number");
|
||||
if (width > 31) {
|
||||
const auto low = r.get(16);
|
||||
return unzigzag(low | (r.get(width - 16) << 16));
|
||||
}
|
||||
return unzigzag(r.get(width));
|
||||
}
|
||||
inline void whole_rotation(BitWriter &w, const QuantBone &bone) {
|
||||
w.put(bone.largest, 2);
|
||||
for (const auto component : bone.rotation) w.put(zigzag(component), rotation_bits);
|
||||
}
|
||||
inline void whole_rotation(BitReader &r, QuantBone &bone) {
|
||||
bone.largest = static_cast<std::uint8_t>(r.get(2));
|
||||
for (auto &component : bone.rotation) component = static_cast<std::int16_t>(unzigzag(r.get(rotation_bits)));
|
||||
}
|
||||
inline void scale(BitWriter &w, const QuantBone &bone) {
|
||||
w.put(bone.scaled, 1);
|
||||
if (bone.scaled)
|
||||
for (const float axis : bone.scale) {
|
||||
const auto bits = std::bit_cast<std::uint32_t>(axis);
|
||||
w.put(bits & 0xffff, 16);
|
||||
w.put(bits >> 16, 16);
|
||||
}
|
||||
}
|
||||
inline void scale(BitReader &r, QuantBone &bone) {
|
||||
bone.scaled = r.get(1) != 0;
|
||||
for (float &axis : bone.scale) {
|
||||
if (!bone.scaled) {
|
||||
axis = 1;
|
||||
continue;
|
||||
}
|
||||
const auto low = static_cast<std::uint32_t>(r.get(16));
|
||||
axis = std::bit_cast<float>(low | (static_cast<std::uint32_t>(r.get(16)) << 16));
|
||||
if (!std::isfinite(axis) || std::abs(axis) > 1000.f) throw std::runtime_error("Invalid pose scale");
|
||||
}
|
||||
}
|
||||
inline bool same_rotation(const QuantBone &a, const QuantBone &b) noexcept {
|
||||
return a.largest == b.largest && std::equal(a.rotation, a.rotation + 3, b.rotation);
|
||||
}
|
||||
inline bool same_position(const QuantBone &a, const QuantBone &b) noexcept { return std::equal(a.position, a.position + 3, b.position); }
|
||||
inline bool same_scale(const QuantBone &a, const QuantBone &b) noexcept {
|
||||
return a.scaled == b.scaled && (!a.scaled || std::equal(a.scale, a.scale + 3, b.scale));
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// How a pose's fingers are sent (finger_bone). `held`: not at all, the receiver keeps the ones it
|
||||
// has (a player too far away to make out a hand). `whole`: every finger bone in full, whatever
|
||||
// the reference holds: the first poses after `held`, until the receiver is known to have them,
|
||||
// since its copy of the reference has old fingers in it.
|
||||
enum class Fingers : std::uint8_t { sent, held, whole };
|
||||
|
||||
// The whole pose, needing no reference: the first of a stream, and whenever the receiver holds
|
||||
// nothing recent enough to build on.
|
||||
inline std::vector<std::uint8_t> encode_whole(const QuantPose &pose) {
|
||||
detail::BitWriter w;
|
||||
w.put(1, 1);
|
||||
w.put(rotation_shift, 3);
|
||||
w.put(pose.skater, 10);
|
||||
w.put(pose.board, 7);
|
||||
for (const auto &bone : pose.bones) {
|
||||
detail::whole_rotation(w, bone);
|
||||
for (const auto mm : bone.position) detail::small(w, mm, 6);
|
||||
detail::scale(w, bone);
|
||||
}
|
||||
return w.finish();
|
||||
}
|
||||
// The pose as its differences from `reference`, which the receiver must hold exactly. Empty when
|
||||
// the two are not the same skeleton: send it whole.
|
||||
inline std::vector<std::uint8_t> encode_delta(const QuantPose &pose, const QuantPose &reference, Fingers fingers = Fingers::sent) {
|
||||
if (pose.skater != reference.skater || pose.board != reference.board || pose.bones.size() != reference.bones.size()) return {};
|
||||
detail::BitWriter w;
|
||||
w.put(0, 1);
|
||||
w.put(rotation_shift, 3);
|
||||
w.put(static_cast<unsigned>(fingers), 2);
|
||||
const auto count = pose.bones.size();
|
||||
const auto listed = [&](std::size_t bone) { return fingers == Fingers::sent || !finger_bone(pose, bone); };
|
||||
// Three lists of the bones that changed, each as how many and then the steps between them.
|
||||
const auto list = [&](auto &&differs, auto &&write) {
|
||||
std::uint32_t changed{};
|
||||
for (std::size_t bone = 0; bone < count; ++bone) changed += listed(bone) && differs(pose.bones[bone], reference.bones[bone]);
|
||||
w.put(changed, 10);
|
||||
std::size_t last{};
|
||||
for (std::size_t bone = 0; bone < count; ++bone) {
|
||||
if (!listed(bone) || !differs(pose.bones[bone], reference.bones[bone])) continue;
|
||||
w.gap(static_cast<std::uint32_t>(bone + 1 - last));
|
||||
last = bone + 1;
|
||||
write(pose.bones[bone], reference.bones[bone]);
|
||||
}
|
||||
};
|
||||
list([](const QuantBone &a, const QuantBone &b) { return !detail::same_rotation(a, b); }, [&](const QuantBone &now, const QuantBone &was) {
|
||||
// A different component left out: the three sent are other ones, so they go whole.
|
||||
const bool flipped = now.largest != was.largest;
|
||||
w.put(flipped, 1);
|
||||
if (flipped) return detail::whole_rotation(w, now);
|
||||
for (unsigned i = 0; i < 3; ++i) detail::small(w, std::int64_t{now.rotation[i]} - was.rotation[i], 4);
|
||||
});
|
||||
list([](const QuantBone &a, const QuantBone &b) { return !detail::same_position(a, b); }, [&](const QuantBone &now, const QuantBone &was) {
|
||||
for (unsigned i = 0; i < 3; ++i) detail::small(w, std::int64_t{now.position[i]} - was.position[i], 6);
|
||||
});
|
||||
list([](const QuantBone &a, const QuantBone &b) { return !detail::same_scale(a, b); },
|
||||
[&](const QuantBone &now, const QuantBone &) { detail::scale(w, now); });
|
||||
if (fingers == Fingers::whole)
|
||||
for (std::size_t bone = 0; bone < count; ++bone) {
|
||||
if (!finger_bone(pose, bone)) continue;
|
||||
detail::whole_rotation(w, pose.bones[bone]);
|
||||
for (const auto mm : pose.bones[bone].position) detail::small(w, mm, 6);
|
||||
detail::scale(w, pose.bones[bone]);
|
||||
}
|
||||
return w.finish();
|
||||
}
|
||||
inline bool whole(std::span<const std::uint8_t> bytes) noexcept { return !bytes.empty() && (bytes[0] & 1); }
|
||||
// The pose `bytes` holds. `reference`: the pose its differences are from, as this end holds it
|
||||
// (ignored by a whole pose). Nothing when the bytes are not a pose, or need a reference of
|
||||
// another skeleton.
|
||||
inline std::optional<QuantPose> decode(std::span<const std::uint8_t> bytes, const QuantPose *reference) noexcept {
|
||||
try {
|
||||
detail::BitReader r{bytes};
|
||||
const bool is_whole = r.get(1) != 0;
|
||||
if (r.get(3) != rotation_shift) return {};
|
||||
const auto valid = [](QuantBone &bone) {
|
||||
if (bone.largest > 3) throw std::runtime_error("Invalid pose rotation");
|
||||
const long most = 32767 >> rotation_shift;
|
||||
for (const auto component : bone.rotation)
|
||||
if (component > most || component < -most) throw std::runtime_error("Invalid pose rotation");
|
||||
for (const auto mm : bone.position)
|
||||
if (mm > position_limit || mm < -position_limit) throw std::runtime_error("Invalid pose position");
|
||||
};
|
||||
QuantPose pose;
|
||||
if (is_whole) {
|
||||
pose.skater = static_cast<std::uint16_t>(r.get(10));
|
||||
pose.board = static_cast<std::uint16_t>(r.get(7));
|
||||
if (pose.skater > max_skater_bones || pose.board > max_board_bones) return {};
|
||||
pose.bones.resize(std::size_t{1} + pose.skater + pose.board);
|
||||
for (auto &bone : pose.bones) {
|
||||
detail::whole_rotation(r, bone);
|
||||
for (auto &mm : bone.position) mm = static_cast<std::int32_t>(std::clamp<std::int64_t>(detail::small(r, 6), INT32_MIN, INT32_MAX));
|
||||
detail::scale(r, bone);
|
||||
valid(bone);
|
||||
}
|
||||
return pose;
|
||||
}
|
||||
if (!reference || reference->bones.size() != std::size_t{1} + reference->skater + reference->board) return {};
|
||||
pose = *reference;
|
||||
const auto fingers = static_cast<Fingers>(r.get(2));
|
||||
if (fingers > Fingers::whole) return {};
|
||||
const auto count = pose.bones.size();
|
||||
const auto list = [&](auto &&read) {
|
||||
const auto changed = r.get(10);
|
||||
std::size_t last{};
|
||||
for (std::uint64_t i = 0; i < changed; ++i) {
|
||||
const auto bone = last + r.gap() - 1;
|
||||
if (bone >= count) throw std::runtime_error("Invalid pose bone");
|
||||
last = bone + 1;
|
||||
read(pose.bones[bone]);
|
||||
valid(pose.bones[bone]);
|
||||
}
|
||||
};
|
||||
list([&](QuantBone &bone) {
|
||||
if (r.get(1)) return detail::whole_rotation(r, bone);
|
||||
for (auto &component : bone.rotation)
|
||||
component = static_cast<std::int16_t>(std::clamp<std::int64_t>(component + detail::small(r, 4), INT16_MIN, INT16_MAX));
|
||||
});
|
||||
list([&](QuantBone &bone) {
|
||||
for (auto &mm : bone.position) mm = static_cast<std::int32_t>(std::clamp<std::int64_t>(mm + detail::small(r, 6), INT32_MIN, INT32_MAX));
|
||||
});
|
||||
list([&](QuantBone &bone) { detail::scale(r, bone); });
|
||||
if (fingers == Fingers::whole)
|
||||
for (std::size_t bone = 0; bone < count; ++bone) {
|
||||
if (!finger_bone(pose, bone)) continue;
|
||||
detail::whole_rotation(r, pose.bones[bone]);
|
||||
for (auto &mm : pose.bones[bone].position) mm = static_cast<std::int32_t>(std::clamp<std::int64_t>(detail::small(r, 6), INT32_MIN, INT32_MAX));
|
||||
detail::scale(r, pose.bones[bone]);
|
||||
valid(pose.bones[bone]);
|
||||
}
|
||||
return pose;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::pose_codec
|
||||
@@ -9,6 +9,7 @@ namespace dingosdk::multiplayer {
|
||||
namespace {
|
||||
constexpr std::uint32_t magic = 0x31504d52; // RMP1, little endian
|
||||
constexpr std::size_t header_size = packet_header_size;
|
||||
std::size_t utf8_length(std::string_view text, std::size_t at) noexcept; // defined with the text checks below
|
||||
std::uint8_t encode_park(unsigned lot, std::string_view choice) {
|
||||
if (choice.empty() || choice == "empty") return 0;
|
||||
for (unsigned family = 0; family < park_families.size(); ++family)
|
||||
@@ -180,9 +181,14 @@ bool valid_roster(std::span<const Member> members, unsigned capacity) noexcept {
|
||||
// A server is in no party; only a party's leader leads or opens it.
|
||||
if ((server && m.party) || (!m.party && (m.party_leader || m.party_open)) || (m.party_open && !m.party_leader))
|
||||
return false;
|
||||
for (unsigned char c : m.name)
|
||||
if (c < 32 || c == 127)
|
||||
// Names reach the game's own UI: whole UTF-8 characters only, and no controls.
|
||||
for (std::size_t at = 0; at < m.name.size();) {
|
||||
const auto length = utf8_length(m.name, at);
|
||||
const auto c = static_cast<unsigned char>(m.name[at]);
|
||||
if (!length || c < 32 || c == 127)
|
||||
return false;
|
||||
at += length;
|
||||
}
|
||||
unsigned leaders = m.party_leader ? 1U : 0U, size = 1;
|
||||
for (std::size_t j = 0; j < members.size(); ++j) {
|
||||
if (j < i && members[j].id == m.id)
|
||||
@@ -231,7 +237,7 @@ bool valid_appearance(const Appearance &a) noexcept {
|
||||
if (a.skater.key != skater_recipe_key || a.skater.version != 2 || a.board.key != board_recipe_key ||
|
||||
a.board.version != 1)
|
||||
return false;
|
||||
std::size_t size = header_size + 12;
|
||||
std::size_t size = header_size + 13 + mark_items * 8;
|
||||
for (const auto *r : {&a.skater, &a.board}) {
|
||||
if (r->scalars.size() > max_cosmetic_scalars || r->items.empty() ||
|
||||
r->items.size() > max_cosmetic_slots)
|
||||
@@ -251,7 +257,7 @@ bool valid_appearance(const Appearance &a) noexcept {
|
||||
size += 8 + item.asset.size() + item.parameters.size() * 4;
|
||||
}
|
||||
}
|
||||
return size <= max_packet;
|
||||
return size <= max_appearance_bytes;
|
||||
}
|
||||
bool valid_transform(const Transform &t) noexcept {
|
||||
for (float v : t.position)
|
||||
@@ -309,6 +315,46 @@ std::string_view greeting_error(const Packet &p, std::uint64_t session, std::uin
|
||||
return "Peer changed level or session. Join again.";
|
||||
return {};
|
||||
}
|
||||
namespace {
|
||||
void coarsen(Transform &t, unsigned bits) noexcept {
|
||||
if (!bits) return;
|
||||
unsigned largest{};
|
||||
float norm{};
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
norm += t.rotation[i] * t.rotation[i];
|
||||
if (std::abs(t.rotation[i]) > std::abs(t.rotation[largest])) largest = i;
|
||||
}
|
||||
if (!(norm > 0.f) || !std::isfinite(norm)) return;
|
||||
// As Writer::compact_transform packs it: the three smaller components, the largest positive.
|
||||
constexpr float scale = 46339.5358f;
|
||||
const float factor = (t.rotation[largest] < 0 ? -1.f : 1.f) * scale / std::sqrt(norm);
|
||||
const float step = static_cast<float>(1U << bits);
|
||||
const float most = std::floor(32767.f / step) * step; // a rounded value must still fit 16 bits
|
||||
float sum{};
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (i == largest) continue;
|
||||
const float packed = std::clamp(std::round(t.rotation[i] * factor / step) * step, -most, most);
|
||||
t.rotation[i] = packed / scale;
|
||||
sum += t.rotation[i] * t.rotation[i];
|
||||
}
|
||||
t.rotation[largest] = std::sqrt(std::max(0.f, 1.f - sum));
|
||||
}
|
||||
} // namespace
|
||||
void limit_bone_scale(Pose &pose, float limit) noexcept {
|
||||
if (!(limit >= 1.f)) return;
|
||||
const auto hold = [&](Transform &t) {
|
||||
for (float &axis : t.scale) axis = std::isfinite(axis) ? std::clamp(axis, 1.f / limit, limit) : 1.f;
|
||||
};
|
||||
hold(pose.root);
|
||||
for (auto &t : pose.skater) hold(t);
|
||||
for (auto &t : pose.board) hold(t);
|
||||
}
|
||||
void coarsen_rotations(Pose &pose, unsigned bits) noexcept {
|
||||
bits = std::min(bits, 12U);
|
||||
coarsen(pose.root, std::min(bits, 6U)); // which way they face matters from further off than a finger does
|
||||
for (auto &t : pose.skater) coarsen(t, bits);
|
||||
for (auto &t : pose.board) coarsen(t, bits);
|
||||
}
|
||||
std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose) {
|
||||
return encode(p, compact_pose, p.pose_interval_us);
|
||||
}
|
||||
@@ -334,6 +380,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
throw std::invalid_argument("Invalid throwdown message");
|
||||
if (p.kind == PacketKind::physics_tuning && (!p.source || p.tuning.size() > max_physics_tuning))
|
||||
throw std::invalid_argument("Invalid physics tuning");
|
||||
if (p.kind == PacketKind::physics_extras && (!p.source || p.extras.size() > max_physics_extras))
|
||||
throw std::invalid_argument("Invalid physics extras");
|
||||
if (p.kind == PacketKind::party && (!p.source || !valid_party_request(p.party_action, p.party_player)))
|
||||
throw std::invalid_argument("Invalid party message");
|
||||
if (p.kind == PacketKind::scoring && (!p.source || (!p.text.empty() && !valid_admin_text(p.text))))
|
||||
@@ -348,7 +396,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
})))
|
||||
throw std::invalid_argument("Invalid ban list");
|
||||
if (p.kind == PacketKind::maps &&
|
||||
(!p.source || p.maps.size() > max_server_maps ||
|
||||
(!p.source || p.maps.size() > max_server_maps || !valid_map_pool(p.map_pool, p.maps.size()) ||
|
||||
p.map_rotation > max_map_rotation ||
|
||||
std::any_of(p.maps.begin(), p.maps.end(), [](const auto &asset) { return !valid_map_asset(asset); })))
|
||||
throw std::invalid_argument("Invalid server map list");
|
||||
if (p.kind == PacketKind::hello && !p.text.empty() && !valid_member_name(p.text))
|
||||
@@ -358,6 +407,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
if (p.kind == PacketKind::map_offer &&
|
||||
(!valid_map_destination(p.destination) || p.map != map_hash(p.destination)))
|
||||
throw std::invalid_argument("Invalid host map destination");
|
||||
if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label))
|
||||
throw std::invalid_argument("Invalid map name");
|
||||
if (p.kind == PacketKind::world_state &&
|
||||
(p.destination.empty() ? (p.map != 0 || p.world_ready)
|
||||
: (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))))
|
||||
@@ -373,7 +424,7 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
p.kind != PacketKind::objects && p.kind != PacketKind::voice && p.kind != PacketKind::chat &&
|
||||
p.kind != PacketKind::admin && p.kind != PacketKind::bans && p.kind != PacketKind::maps &&
|
||||
p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning &&
|
||||
p.kind != PacketKind::party && p.kind != PacketKind::scoring)
|
||||
p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras)
|
||||
throw std::invalid_argument("Unknown packet kind");
|
||||
const auto payload = greeting ? 72
|
||||
: p.kind == PacketKind::away
|
||||
@@ -400,6 +451,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(p.map_authorized ? 1 : 0, 1);
|
||||
w.integer(p.destination.size(), 2);
|
||||
w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end());
|
||||
w.integer(p.map_label.size(), 1);
|
||||
w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end());
|
||||
}
|
||||
if (p.kind == PacketKind::hello) {
|
||||
w.integer(p.text.size(), 1);
|
||||
@@ -410,6 +463,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(p.world_ready ? 1 : 0, 1);
|
||||
w.integer(p.destination.size(), 2);
|
||||
w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end());
|
||||
w.integer(p.map_label.size(), 1);
|
||||
w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end());
|
||||
} else if (p.kind == PacketKind::world_ready) {
|
||||
w.integer(p.world_ready ? 1 : 0, 1);
|
||||
} else if (p.kind == PacketKind::pose) {
|
||||
@@ -437,6 +492,13 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(p.appearance.card.background, 4);
|
||||
w.integer(p.appearance.card.emblem, 4);
|
||||
w.integer(p.appearance.card.title, 4);
|
||||
w.integer((p.appearance.hide_tag ? 1 : 0) | (p.appearance.hide_items ? 2 : 0), 1);
|
||||
for (const auto &style : p.appearance.marks) {
|
||||
w.integer(static_cast<std::uint8_t>(style.mode), 1);
|
||||
for (const auto part : style.from) w.integer(part, 1);
|
||||
for (const auto part : style.to) w.integer(part, 1);
|
||||
w.integer(style.speed, 1);
|
||||
}
|
||||
} else if (p.kind == PacketKind::audio) {
|
||||
w.integer(p.audio.size(), 2);
|
||||
AudioState previous;
|
||||
@@ -485,6 +547,10 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(p.tuning.size(), 2);
|
||||
w.bytes.insert(w.bytes.end(), p.tuning.begin(), p.tuning.end());
|
||||
}
|
||||
if (p.kind == PacketKind::physics_extras) {
|
||||
w.integer(p.extras.size(), 2);
|
||||
w.bytes.insert(w.bytes.end(), p.extras.begin(), p.extras.end());
|
||||
}
|
||||
if (p.kind == PacketKind::scoring) {
|
||||
w.integer(p.scoring, 8);
|
||||
w.integer(p.text.size(), 2);
|
||||
@@ -496,6 +562,9 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(asset.size(), 1);
|
||||
w.bytes.insert(w.bytes.end(), asset.begin(), asset.end());
|
||||
}
|
||||
w.integer(p.map_pool.size(), 2);
|
||||
for (const auto index : p.map_pool) w.integer(index, 2);
|
||||
w.integer(p.map_rotation, 2);
|
||||
}
|
||||
if (p.kind == PacketKind::bans) {
|
||||
w.integer(p.ban_total, 4);
|
||||
@@ -528,7 +597,11 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.integer(p.object_clears, 4);
|
||||
if (!valid_multiplayer_tps(p.tps)) throw std::invalid_argument("Invalid session TPS");
|
||||
w.integer(p.tps, 1);
|
||||
w.integer(p.chat_badge & 0xffffff, 3); // red, green, blue
|
||||
w.integer(p.chat_text & 0xffffff, 3);
|
||||
w.integer(static_cast<std::uint8_t>(p.object_placement), 1);
|
||||
if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit");
|
||||
w.integer(p.object_limit, 2);
|
||||
w.integer(p.force_world_layers, 1);
|
||||
// One mode per world-layer catalog row; both peers read the same catalog
|
||||
// from the same game build.
|
||||
@@ -573,6 +646,13 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
w.bytes[8 + i] = static_cast<std::uint8_t>(actual_payload >> (8 * i));
|
||||
return std::move(w.bytes);
|
||||
}
|
||||
static bool read_map_label(Reader &r, std::span<const std::uint8_t> bytes, Packet &p) {
|
||||
const auto length = r.integer(1);
|
||||
if (length > max_member_name || length > bytes.size() - r.at) return false;
|
||||
p.map_label.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
|
||||
r.at += static_cast<std::size_t>(length);
|
||||
return valid_map_label(p.map_label);
|
||||
}
|
||||
std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
try {
|
||||
if (bytes.size() < header_size || bytes.size() > max_packet)
|
||||
@@ -611,7 +691,7 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
p.destination.assign(reinterpret_cast<const char *>(bytes.data() + r.at),
|
||||
static_cast<std::size_t>(length));
|
||||
r.at += static_cast<std::size_t>(length);
|
||||
if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))
|
||||
if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p))
|
||||
return {};
|
||||
}
|
||||
if (p.kind == PacketKind::hello) {
|
||||
@@ -636,6 +716,7 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
if (p.destination.empty() ? (p.map != 0 || p.world_ready)
|
||||
: (!valid_map_destination(p.destination) || p.map != map_hash(p.destination)))
|
||||
return {};
|
||||
if (!read_map_label(r, bytes, p)) return {};
|
||||
}
|
||||
} else if (p.kind == PacketKind::pose) {
|
||||
const auto skater = r.integer(2), board = r.integer(2);
|
||||
@@ -662,7 +743,18 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
p.appearance.card.background = static_cast<std::uint32_t>(r.integer(4));
|
||||
p.appearance.card.emblem = static_cast<std::uint32_t>(r.integer(4));
|
||||
p.appearance.card.title = static_cast<std::uint32_t>(r.integer(4));
|
||||
if (!p.map || !valid_appearance(p.appearance))
|
||||
const auto flags = r.integer(1);
|
||||
p.appearance.hide_tag = (flags & 1) != 0;
|
||||
p.appearance.hide_items = (flags & 2) != 0;
|
||||
bool styled = true;
|
||||
for (auto &style : p.appearance.marks) {
|
||||
style.mode = static_cast<MarkMode>(r.integer(1));
|
||||
for (auto &part : style.from) part = static_cast<std::uint8_t>(r.integer(1));
|
||||
for (auto &part : style.to) part = static_cast<std::uint8_t>(r.integer(1));
|
||||
style.speed = static_cast<std::uint8_t>(r.integer(1));
|
||||
styled = styled && valid_mark_style(style);
|
||||
}
|
||||
if (flags > 3 || !styled || !p.map || !valid_appearance(p.appearance))
|
||||
return {};
|
||||
} else if (p.kind == PacketKind::audio) {
|
||||
const auto count = r.integer(2);
|
||||
@@ -717,6 +809,12 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
return {};
|
||||
p.tuning.assign(bytes.begin() + static_cast<std::ptrdiff_t>(r.at), bytes.end());
|
||||
r.at = bytes.size();
|
||||
} else if (p.kind == PacketKind::physics_extras) {
|
||||
const auto length = r.integer(2);
|
||||
if (!p.source || length > max_physics_extras || length != bytes.size() - r.at)
|
||||
return {};
|
||||
p.extras.assign(bytes.begin() + static_cast<std::ptrdiff_t>(r.at), bytes.end());
|
||||
r.at = bytes.size();
|
||||
} else if (p.kind == PacketKind::scoring) {
|
||||
p.scoring = r.integer(8);
|
||||
const auto length = r.integer(2);
|
||||
@@ -753,6 +851,11 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
if (!valid_map_asset(asset)) return {};
|
||||
p.maps.push_back(std::move(asset));
|
||||
}
|
||||
const auto pooled = r.integer(2);
|
||||
if (pooled > p.maps.size()) return {};
|
||||
for (std::uint64_t i = 0; i < pooled; ++i) p.map_pool.push_back(static_cast<std::uint16_t>(r.integer(2)));
|
||||
p.map_rotation = static_cast<std::uint16_t>(r.integer(2));
|
||||
if (!valid_map_pool(p.map_pool, p.maps.size()) || p.map_rotation > max_map_rotation) return {};
|
||||
} else if (p.kind == PacketKind::bans) {
|
||||
p.ban_total = static_cast<std::uint32_t>(r.integer(4));
|
||||
const auto count = r.integer(2);
|
||||
@@ -802,9 +905,14 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
p.object_clears = static_cast<std::uint32_t>(r.integer(4));
|
||||
p.tps = static_cast<unsigned>(r.integer(1));
|
||||
if (!valid_multiplayer_tps(p.tps)) return {};
|
||||
p.chat_badge = 0xff000000U | static_cast<std::uint32_t>(r.integer(3));
|
||||
p.chat_text = 0xff000000U | static_cast<std::uint32_t>(r.integer(3));
|
||||
const auto placement = r.integer(1);
|
||||
if (!valid_object_placement(placement)) return {};
|
||||
p.object_placement = static_cast<ObjectPlacement>(placement);
|
||||
const auto object_limit = r.integer(2);
|
||||
if (!valid_object_limit(object_limit)) return {};
|
||||
p.object_limit = static_cast<unsigned>(object_limit);
|
||||
const auto forced = r.integer(1);
|
||||
if (forced > 1) return {};
|
||||
p.force_world_layers = forced != 0;
|
||||
@@ -922,6 +1030,15 @@ bool valid_map_asset(std::string_view asset) noexcept {
|
||||
return !asset.empty() && asset.size() <= max_map_asset &&
|
||||
std::all_of(asset.begin(), asset.end(), [](char c) { return c > 32 && c < 127 && c != '|'; });
|
||||
}
|
||||
bool valid_map_label(std::string_view label) noexcept {
|
||||
return label.empty() || (label.size() <= max_member_name && valid_member_name(label));
|
||||
}
|
||||
bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept {
|
||||
if (pool.size() > maps) return false;
|
||||
for (std::size_t i = 0; i < pool.size(); ++i)
|
||||
if (pool[i] >= maps || std::find(pool.begin(), pool.begin() + i, pool[i]) != pool.begin() + i) return false;
|
||||
return true;
|
||||
}
|
||||
bool valid_admin_text(std::string_view text) noexcept {
|
||||
if (text.empty() || text.size() > max_admin_text) return false;
|
||||
for (std::size_t at = 0; at < text.size();) {
|
||||
@@ -950,6 +1067,15 @@ std::string clean_chat_text(std::string_view text) {
|
||||
if (first == std::string::npos) return {};
|
||||
return result.substr(first, result.find_last_not_of(' ') - first + 1);
|
||||
}
|
||||
std::string clean_roster_name(std::string_view text) {
|
||||
auto name = clean_chat_text(text);
|
||||
if (name.size() > 128) {
|
||||
std::size_t cut = 128;
|
||||
while (cut && (static_cast<unsigned char>(name[cut]) & 0xC0) == 0x80) --cut;
|
||||
name.resize(cut);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
bool newer_sequence(std::uint32_t a, std::uint32_t b) noexcept {
|
||||
const auto d = a - b;
|
||||
return d && d < 0x80000000u;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "Engine/Game/Multiplayer/distance_settings.h"
|
||||
#include "Engine/Game/Multiplayer/object_placement.h"
|
||||
#include "Engine/Game/Multiplayer/session_model.h"
|
||||
#include "Engine/Game/Multiplayer/session_physics.h"
|
||||
#include "Engine/Game/Multiplayer/tick_settings.h"
|
||||
#include "Engine/Game/World/park_rotation.h"
|
||||
#include "Engine/Game/World/world_layers.h"
|
||||
@@ -24,7 +25,9 @@ namespace dingosdk::multiplayer {
|
||||
constexpr std::size_t max_skater_bones = 512, max_board_bones = 64;
|
||||
constexpr std::size_t max_packet = 24576;
|
||||
constexpr std::size_t packet_header_size = 64;
|
||||
constexpr std::uint16_t protocol_version = 38;
|
||||
constexpr std::uint16_t protocol_version = 45;
|
||||
// A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF).
|
||||
inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U;
|
||||
constexpr std::size_t max_throwdown_message = 4096;
|
||||
// Packet::tuning: the host's SkatePhysicsTuning differences (Extension/Skater/physics_tuning.h).
|
||||
constexpr std::size_t max_physics_tuning = 16384;
|
||||
@@ -64,16 +67,20 @@ enum class PacketKind : std::uint16_t {
|
||||
// The host's skate physics tuning, as differences from the game's own (opaque here;
|
||||
// empty = the game's). Only the host sends it; a dedicated server never needs to.
|
||||
physics_tuning = 27,
|
||||
// A party request to a dedicated server, or its notice to one player (PartyAction).
|
||||
// A party request to whoever hosts, or its notice to one player (PartyAction).
|
||||
party = 28,
|
||||
// A player telling the host or dedicated server how its mods change trick scoring
|
||||
// (Engine/Vfs/mod_scoring.h): sent once known, again whenever it changes.
|
||||
scoring = 29
|
||||
scoring = 29,
|
||||
// The host's physics that its tuning does not carry: the trainer's tuning-class values and
|
||||
// trick multipliers (Engine/Game/Multiplayer/session_physics.h; opaque here, empty = the
|
||||
// game's own). Like physics_tuning, only the host sends it and a dedicated server never does.
|
||||
physics_extras = 30
|
||||
};
|
||||
// Packet::party_action. Requests go from a player to the dedicated server (party_player =
|
||||
// the other player involved, 0 for leave/open/close); invited and withdrawn go from the
|
||||
// server to the invitee (party_player = the inviter). The server answers everything else
|
||||
// with a chat line and the next roster.
|
||||
// Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a
|
||||
// lobby's host (party_player = the other player involved, 0 for leave/open/close); invited
|
||||
// and withdrawn go from the host to the invitee (party_player = the inviter). The host
|
||||
// answers everything else with a chat line and the next roster.
|
||||
enum class PartyAction : std::uint8_t {
|
||||
invite = 1, // invite party_player into the sender's party
|
||||
accept = 2, // accept party_player's invite
|
||||
@@ -84,13 +91,13 @@ enum class PartyAction : std::uint8_t {
|
||||
promote = 7, // the leader hands the lead to party_player
|
||||
open = 8, // the leader lets anyone join
|
||||
close = 9, // the leader makes the party invite-only
|
||||
invited = 10, // server -> invitee: party_player invited you
|
||||
withdrawn = 11 // server -> invitee: party_player's invite can no longer be accepted
|
||||
invited = 10, // host -> invitee: party_player invited you
|
||||
withdrawn = 11 // host -> invitee: party_player's invite can no longer be accepted
|
||||
};
|
||||
bool valid_party_request(PartyAction action, std::uint64_t player) noexcept;
|
||||
// Steam accounts in the public universe. Players are individual accounts; a
|
||||
// dedicated server signs in anonymously as a game server (type 3 or 4) and gets
|
||||
// a new ID each time it starts.
|
||||
// dedicated server is a game server: anonymous (type 4) with a new ID each time
|
||||
// it starts, or signed in with a login token (type 3) with the same ID always.
|
||||
inline bool individual_steam_id(std::uint64_t id) noexcept {
|
||||
return (id >> 56) == 1 && ((id >> 52) & 15) == 1 && (id & 0xffffffffULL);
|
||||
}
|
||||
@@ -98,6 +105,9 @@ inline bool game_server_steam_id(std::uint64_t id) noexcept {
|
||||
const auto type = (id >> 52) & 15;
|
||||
return (id >> 56) == 1 && (type == 3 || type == 4) && (id & 0xffffffffULL);
|
||||
}
|
||||
inline bool persistent_server_steam_id(std::uint64_t id) noexcept {
|
||||
return game_server_steam_id(id) && ((id >> 52) & 15) == 3;
|
||||
}
|
||||
// A player's own name as sent in their hello: at most this many bytes.
|
||||
constexpr std::size_t max_member_name = 64;
|
||||
constexpr std::size_t max_admin_text = 320;
|
||||
@@ -105,6 +115,8 @@ constexpr std::size_t max_ban_rows = 256;
|
||||
constexpr std::size_t max_server_maps = 128, max_map_asset = 128;
|
||||
// A level asset as a server's map list carries it: printable ASCII, no '|'.
|
||||
bool valid_map_asset(std::string_view asset) noexcept;
|
||||
bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept; // distinct indices below `maps`
|
||||
bool valid_map_label(std::string_view label) noexcept; // empty, or a name like a member's
|
||||
struct Transform {
|
||||
std::array<float, 3> position{};
|
||||
std::array<float, 4> rotation{0, 0, 0, 1};
|
||||
@@ -122,7 +134,7 @@ struct Member {
|
||||
std::string name;
|
||||
bool admin{}; // roster: may change a dedicated server's settings
|
||||
// roster: the player's party (0 = none), whether they lead it, and (on the leader) whether
|
||||
// anyone may join it. Each party has exactly one leader. A listen host's lobby is one party.
|
||||
// anyone may join it. Each party has exactly one leader.
|
||||
std::uint32_t party{};
|
||||
bool party_leader{}, party_open{};
|
||||
// roster: a dedicated server measured the player's game running fast (a speedhack): nobody
|
||||
@@ -161,9 +173,14 @@ struct Packet {
|
||||
// lock their editor and native tools from it; the host enforces it by
|
||||
// freezing guest layouts (see publish_guest_objects).
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
// Objects each player may have placed (object_placement.h); 0: no limit.
|
||||
unsigned object_limit{};
|
||||
// Bumped each time the host deletes all guest objects. Guests delete their
|
||||
// own session objects when it changes after their first roster.
|
||||
std::uint32_t object_clears{};
|
||||
// Roster: the colours of a dedicated server's own chat lines, its badge and name and the
|
||||
// text after them (IM_COL32 layout; the server's owner chooses them).
|
||||
std::uint32_t chat_badge = default_server_chat_badge, chat_text = default_server_chat_text;
|
||||
bool force_world_layers{};
|
||||
WorldLayerState layers = WorldLayerState(world_layers().size()); // all "default"
|
||||
ParkChoices parks; // One allowlisted selection byte per shared native park slot.
|
||||
@@ -182,9 +199,13 @@ struct Packet {
|
||||
std::vector<MultiplayerBan> bans; // bans: newest first
|
||||
std::uint32_t ban_total{}; // bans: how many the server has in all
|
||||
std::vector<std::string> maps; // maps: level assets
|
||||
std::vector<std::uint16_t> map_pool; // maps: the pool as indices into `maps`, rotation order (empty: every map)
|
||||
std::uint16_t map_rotation{}; // maps: minutes per map (0: off)
|
||||
std::string map_label; // map_offer, world_state: the map's name for people (may be empty)
|
||||
std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes)
|
||||
std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position)
|
||||
std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes
|
||||
std::vector<std::uint8_t> extras; // physics_extras: 0..max_physics_extras bytes
|
||||
PartyAction party_action = PartyAction::leave; // party: what is asked or told
|
||||
std::uint64_t party_player{}; // party: the other player (see PartyAction)
|
||||
// scoring: the sender's scoring fingerprint, 0 for the game's own; `text` names the mods
|
||||
@@ -205,8 +226,21 @@ bool valid_admin_text(std::string_view) noexcept;
|
||||
// The message a player typed, made valid: control characters and broken UTF-8
|
||||
// dropped, surrounding blanks trimmed, cut to the byte limit on a character boundary.
|
||||
std::string clean_chat_text(std::string_view);
|
||||
// A player's name as a roster carries it (valid_roster): the same cleaning, at most 128 bytes.
|
||||
std::string clean_roster_name(std::string_view);
|
||||
bool valid_pose(const Pose &) noexcept;
|
||||
std::vector<std::uint8_t> encode(const Packet &, bool compact_pose = false);
|
||||
// Rounds every rotation in a pose to a multiple of 2^bits in the 16-bit form a compact pose
|
||||
// packs it in (the root to at most 2^6). The pose still encodes and decodes as any other;
|
||||
// what changes is that a bone turning by less than a step is the same bytes as before, so a
|
||||
// difference from a reference leaves it out, and the low bits of the ones that did change are
|
||||
// zero and pack away. One step is about 0.0025 degrees times 2^bits: 4 bits is 0.04 degrees,
|
||||
// 7 bits a third of a degree.
|
||||
void coarsen_rotations(Pose &pose, unsigned bits) noexcept;
|
||||
// Keeps every bone's scale within 1/limit to limit on each axis (1: no scaling at all). A mod
|
||||
// that resizes part of a skater (a head four times the size) does it with a bone's scale,
|
||||
// which travels in the pose and so shows to everyone, mod or not.
|
||||
void limit_bone_scale(Pose &pose, float limit) noexcept;
|
||||
// The same, with a pose encoded at another update interval (a recipient thinned by
|
||||
// distance) instead of the packet's own, so the packet need not be copied for it.
|
||||
std::vector<std::uint8_t> encode(const Packet &, bool compact_pose, std::uint32_t pose_interval_us);
|
||||
@@ -304,6 +338,8 @@ class PoseBuffer {
|
||||
bool sample(std::uint64_t now_us, Pose &out) const;
|
||||
std::optional<Pose> sample(std::uint64_t now_us) const;
|
||||
bool sample_remote(std::uint64_t now_us, Pose &out);
|
||||
// Whether a pose arrived within this long: sampling gives up after a second without one.
|
||||
bool heard_within(std::uint64_t now_us, std::uint64_t age_us) const;
|
||||
std::optional<Pose> sample_remote(std::uint64_t now_us);
|
||||
PosePlayback playback() const { return playback_; }
|
||||
void clear();
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#pragma once
|
||||
#include "Extension/Multiplayer/Remote/audio_state.h"
|
||||
#include "pose_batch.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
// Skaters' sound between a dedicated server and its players: several players' samples in one
|
||||
// message, each sample as what changed since the one before it.
|
||||
//
|
||||
// A sample is 58 numbers, 26 selectors and 43 switches, of which about twenty numbers change from
|
||||
// one to the next and the rest seldom. Numbers are rounded to 1/1024 and sent as how far they
|
||||
// moved; a switch or selector is sent only when one changed. Messages go reliably and in order
|
||||
// (many samples are a pulse one sample long, which must not be lost), so each builds on the last
|
||||
// one sent: the sender keeps what the receiver holds of each stream. A stream is sent whole when
|
||||
// it starts and again every couple of seconds, which also puts right a receiver that missed its
|
||||
// start.
|
||||
namespace dingosdk::multiplayer::sound_codec {
|
||||
inline constexpr std::array<std::uint8_t, 4> magic{'R', 'M', 'S', '1'};
|
||||
inline constexpr std::size_t header_size = 12;
|
||||
inline constexpr std::int64_t value_scale = 1024, value_limit = 1000000LL * value_scale;
|
||||
// Ages are sent in steps of 256 us.
|
||||
inline constexpr unsigned age_shift = 8;
|
||||
inline constexpr std::uint32_t age_limit = 1000000U >> age_shift;
|
||||
inline constexpr std::uint64_t whole_every_us = 2000000;
|
||||
|
||||
inline bool is_sound(std::span<const std::uint8_t> bytes) noexcept {
|
||||
return bytes.size() >= header_size && std::equal(magic.begin(), magic.end(), bytes.begin());
|
||||
}
|
||||
|
||||
struct QuantState {
|
||||
std::array<std::int32_t, audio_float_count> values{};
|
||||
std::array<std::uint32_t, audio_selector_count> selectors{};
|
||||
std::uint64_t flags{}; // a bit each
|
||||
bool operator==(const QuantState &) const = default;
|
||||
};
|
||||
// A number that is not nothing never rounds to nothing: the game may ask only whether it is.
|
||||
inline QuantState quantize(const AudioState &state) noexcept {
|
||||
QuantState out;
|
||||
for (std::size_t i = 0; i < audio_float_count; ++i) {
|
||||
const double value = std::isfinite(state.values[i]) ? static_cast<double>(state.values[i]) : 0.0;
|
||||
auto rounded = std::llround(std::clamp(value * static_cast<double>(value_scale), -static_cast<double>(value_limit), static_cast<double>(value_limit)));
|
||||
if (!rounded && value != 0.0) rounded = value > 0 ? 1 : -1;
|
||||
out.values[i] = static_cast<std::int32_t>(rounded);
|
||||
}
|
||||
out.selectors = state.selectors;
|
||||
// (Never the first switch: a remote skater's sound is not the local player's mix.)
|
||||
for (std::size_t i = 1; i < audio_flag_count; ++i)
|
||||
if (state.flags[i]) out.flags |= std::uint64_t{1} << i;
|
||||
return out;
|
||||
}
|
||||
inline AudioState restore(const QuantState &state) noexcept {
|
||||
AudioState out;
|
||||
for (std::size_t i = 0; i < audio_float_count; ++i)
|
||||
out.values[i] = static_cast<float>(static_cast<double>(state.values[i]) / static_cast<double>(value_scale));
|
||||
out.selectors = state.selectors;
|
||||
for (std::size_t i = 0; i < audio_flag_count; ++i) out.flags[i] = (state.flags >> i) & 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
using pose_batch::detail::put;
|
||||
using pose_batch::detail::Reader;
|
||||
using pose_batch::detail::unzigzag;
|
||||
using pose_batch::detail::varint;
|
||||
using pose_batch::detail::zigzag;
|
||||
// Eight bytes say which numbers changed (a bit each) and, above those, whether the sample is an
|
||||
// event, whether the switches follow and whether selectors do.
|
||||
inline constexpr unsigned event_bit = 58, flags_bit = 59, selectors_bit = 60;
|
||||
inline void put_sample(std::vector<std::uint8_t> &out, const QuantState &now, const QuantState &was, std::uint32_t age_us, bool event) {
|
||||
std::uint64_t changed{};
|
||||
std::uint32_t selectors{};
|
||||
for (std::size_t i = 0; i < audio_float_count; ++i)
|
||||
if (now.values[i] != was.values[i]) changed |= std::uint64_t{1} << i;
|
||||
for (std::size_t i = 0; i < audio_selector_count; ++i)
|
||||
if (now.selectors[i] != was.selectors[i]) selectors |= 1U << i;
|
||||
const bool flags = now.flags != was.flags;
|
||||
put(out, changed | (std::uint64_t{event} << event_bit) | (std::uint64_t{flags} << flags_bit) | (std::uint64_t{selectors != 0} << selectors_bit), 8);
|
||||
varint(out, std::min(age_us >> age_shift, age_limit));
|
||||
for (std::size_t i = 0; i < audio_float_count; ++i)
|
||||
if (changed & (std::uint64_t{1} << i)) varint(out, zigzag(std::int64_t{now.values[i]} - std::int64_t{was.values[i]}));
|
||||
if (flags) put(out, now.flags, 6);
|
||||
if (selectors) {
|
||||
put(out, selectors, 4);
|
||||
for (std::size_t i = 0; i < audio_selector_count; ++i)
|
||||
if (selectors & (1U << i)) put(out, now.selectors[i], 4);
|
||||
}
|
||||
}
|
||||
// Throws on bytes that are not a sample or that make an impossible one.
|
||||
inline QuantState get_sample(Reader &r, const QuantState &was, std::uint32_t &age_us, bool &event) {
|
||||
const auto head = r.get(8);
|
||||
if (head >> (selectors_bit + 1)) throw std::runtime_error("Invalid sound sample");
|
||||
const auto age = r.varint();
|
||||
if (age > age_limit) throw std::runtime_error("Invalid sound sample age");
|
||||
age_us = static_cast<std::uint32_t>(age) << age_shift;
|
||||
event = (head >> event_bit) & 1;
|
||||
QuantState now = was;
|
||||
for (std::size_t i = 0; i < audio_float_count; ++i) {
|
||||
if (!(head & (std::uint64_t{1} << i))) continue;
|
||||
const auto step = unzigzag(r.varint());
|
||||
if (step > 2 * value_limit || step < -2 * value_limit) throw std::runtime_error("Invalid sound value");
|
||||
const auto value = std::int64_t{was.values[i]} + step;
|
||||
if (value > value_limit || value < -value_limit) throw std::runtime_error("Invalid sound value");
|
||||
now.values[i] = static_cast<std::int32_t>(value);
|
||||
}
|
||||
if ((head >> flags_bit) & 1) {
|
||||
now.flags = r.get(6);
|
||||
if (now.flags >> audio_flag_count || (now.flags & 1)) throw std::runtime_error("Invalid sound switches");
|
||||
}
|
||||
if ((head >> selectors_bit) & 1) {
|
||||
const auto selectors = r.get(4);
|
||||
if (!selectors || selectors >> audio_selector_count) throw std::runtime_error("Invalid sound selectors");
|
||||
for (std::size_t i = 0; i < audio_selector_count; ++i)
|
||||
if (selectors & (std::uint64_t{1} << i)) now.selectors[i] = static_cast<std::uint32_t>(r.get(4));
|
||||
}
|
||||
return now;
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// The sending side of one connection: what its receiver holds of each player's sound. begin(),
|
||||
// then add() for each player with samples, then the message goes reliably and sent() is told
|
||||
// whether it went: what the receiver holds moves on only when it did.
|
||||
class Sender {
|
||||
public:
|
||||
void begin(std::uint64_t world, std::uint64_t map) {
|
||||
message_.assign(magic.begin(), magic.end());
|
||||
detail::put(message_, world & 0xffffffff, 4);
|
||||
detail::put(message_, map & 0xffffffff, 4);
|
||||
staged_.clear();
|
||||
}
|
||||
void add(std::uint64_t source, std::uint64_t epoch, std::uint32_t sequence, std::uint64_t time_us, std::span<const AudioSample> samples, std::uint64_t now) {
|
||||
if (samples.empty() || samples.size() > max_audio_samples) return;
|
||||
Out out;
|
||||
if (const auto staged = staged_.find(source); staged != staged_.end()) out = staged->second;
|
||||
else if (const auto found = streams_.find(source); found != streams_.end()) out = found->second;
|
||||
if (!out.stream || out.epoch != epoch) {
|
||||
out = {};
|
||||
out.epoch = epoch;
|
||||
out.stream = next_stream_++;
|
||||
if (!next_stream_) next_stream_ = 1;
|
||||
}
|
||||
const bool whole = !out.started || sequence <= out.sequence || now - out.whole_at >= whole_every_us;
|
||||
detail::varint(message_, out.stream);
|
||||
message_.push_back(static_cast<std::uint8_t>((whole ? 1U : 0U) | (static_cast<unsigned>(samples.size()) << 1)));
|
||||
if (whole) {
|
||||
detail::put(message_, source, 8);
|
||||
detail::put(message_, epoch, 8);
|
||||
detail::put(message_, sequence, 4);
|
||||
detail::put(message_, time_us, 8);
|
||||
out.whole_at = now;
|
||||
} else {
|
||||
detail::varint(message_, sequence - out.sequence);
|
||||
detail::varint(message_, detail::zigzag(static_cast<std::int64_t>(time_us) - static_cast<std::int64_t>(out.time_us)));
|
||||
}
|
||||
QuantState was = whole ? QuantState{} : out.state;
|
||||
for (const auto &sample : samples) {
|
||||
const auto state = quantize(sample.state);
|
||||
detail::put_sample(message_, state, was, sample.age_us, sample.event);
|
||||
was = state;
|
||||
}
|
||||
out.state = was;
|
||||
out.sequence = sequence;
|
||||
out.time_us = time_us;
|
||||
out.started = true;
|
||||
staged_[source] = out;
|
||||
}
|
||||
bool pending() const noexcept { return !staged_.empty(); }
|
||||
std::span<const std::uint8_t> message() const noexcept { return message_; }
|
||||
void sent(bool went) {
|
||||
if (went)
|
||||
for (const auto &[source, out] : staged_) streams_[source] = out;
|
||||
staged_.clear();
|
||||
message_.clear();
|
||||
}
|
||||
// The player left: their stream is nobody's.
|
||||
void forget(std::uint64_t source) { streams_.erase(source); }
|
||||
// A new world: every stream starts over.
|
||||
void restart() {
|
||||
streams_.clear();
|
||||
staged_.clear();
|
||||
message_.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Out {
|
||||
std::uint64_t epoch{}, time_us{}, whole_at{};
|
||||
std::uint32_t sequence{};
|
||||
std::uint16_t stream{};
|
||||
bool started{};
|
||||
QuantState state;
|
||||
};
|
||||
std::map<std::uint64_t, Out> streams_, staged_;
|
||||
std::vector<std::uint8_t> message_;
|
||||
std::uint16_t next_stream_ = 1;
|
||||
};
|
||||
|
||||
// The receiving side of one stream: whose it is and the last sample of it.
|
||||
struct In {
|
||||
std::uint64_t source{}, epoch{}, time_us{};
|
||||
std::uint32_t sequence{};
|
||||
QuantState state;
|
||||
};
|
||||
// One player's samples from a message, as the packet of sound it would have been.
|
||||
struct Heard {
|
||||
std::uint64_t source{}, epoch{}, time_us{};
|
||||
std::uint32_t sequence{};
|
||||
std::vector<AudioSample> samples;
|
||||
};
|
||||
// What a message holds, or nothing when it is not one or is damaged. A message of another world
|
||||
// holds nothing; samples of a stream this side was never told of are passed over. `streams` moves
|
||||
// on with what is read.
|
||||
template <typename Streams>
|
||||
std::optional<std::vector<Heard>> read(Streams &streams, std::span<const std::uint8_t> bytes, std::uint64_t world, std::uint64_t map) noexcept {
|
||||
try {
|
||||
if (!is_sound(bytes)) return {};
|
||||
detail::Reader r{bytes, 4};
|
||||
std::vector<Heard> out;
|
||||
if (r.get(4) != (world & 0xffffffff) || r.get(4) != (map & 0xffffffff)) return out;
|
||||
while (r.at < bytes.size()) {
|
||||
const auto stream = r.varint();
|
||||
if (stream > 0xffff) return {};
|
||||
const auto head = r.get(1);
|
||||
const bool whole = head & 1;
|
||||
const auto count = static_cast<std::size_t>(head >> 1);
|
||||
if (!count || count > max_audio_samples) return {};
|
||||
In in;
|
||||
bool known = whole;
|
||||
if (whole) {
|
||||
in.source = r.get(8);
|
||||
in.epoch = r.get(8);
|
||||
in.sequence = static_cast<std::uint32_t>(r.get(4));
|
||||
in.time_us = r.get(8);
|
||||
} else {
|
||||
const auto ahead = r.varint();
|
||||
const auto time_step = detail::unzigzag(r.varint());
|
||||
if (!ahead || ahead > 0xffffff) return {};
|
||||
if (const auto found = streams.find(static_cast<std::uint16_t>(stream)); found != streams.end()) {
|
||||
in = found->second;
|
||||
known = true;
|
||||
}
|
||||
in.sequence += static_cast<std::uint32_t>(ahead);
|
||||
in.time_us = static_cast<std::uint64_t>(static_cast<std::int64_t>(in.time_us) + time_step);
|
||||
}
|
||||
Heard heard{in.source, in.epoch, in.time_us, in.sequence, {}};
|
||||
heard.samples.reserve(count);
|
||||
QuantState was = whole ? QuantState{} : in.state;
|
||||
std::uint32_t last_age = 1000000;
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
std::uint32_t age{};
|
||||
bool event{};
|
||||
was = detail::get_sample(r, was, age, event);
|
||||
if (age > last_age) return {};
|
||||
last_age = age;
|
||||
heard.samples.push_back({age, restore(was), event});
|
||||
}
|
||||
if (!known) continue;
|
||||
in.state = was;
|
||||
streams[static_cast<std::uint16_t>(stream)] = in;
|
||||
out.push_back(std::move(heard));
|
||||
if (out.size() > 512) return {};
|
||||
}
|
||||
return out;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::sound_codec
|
||||
@@ -9,6 +9,9 @@ namespace dingosdk::multiplayer {
|
||||
inline constexpr std::uint32_t skater_recipe_key = 2759515148U, board_recipe_key = 1583459055U;
|
||||
inline constexpr std::size_t max_cosmetic_slots = 64, max_cosmetic_scalars = 64,
|
||||
max_cosmetic_parameters = 128, max_cosmetic_asset = 255;
|
||||
// The largest encoded outfit packet. Well under the packet limit, so an outfit a host accepts
|
||||
// always fits again with the reference marker a relayed copy carries.
|
||||
inline constexpr std::size_t max_appearance_bytes = 16384;
|
||||
struct CosmeticSlot {
|
||||
std::uint32_t slot{};
|
||||
std::string asset;
|
||||
@@ -25,9 +28,37 @@ struct PlayerCard {
|
||||
std::uint32_t background{}, emblem{}, title{};
|
||||
bool operator==(const PlayerCard &) const = default;
|
||||
};
|
||||
// How one of a listed player's marked cosmetics is coloured (developer_identity.h), chosen on
|
||||
// the menu's Special page and carried in their Appearance, so everyone sees the same.
|
||||
enum class MarkMode : std::uint8_t { standard, off, gradient, solid };
|
||||
struct MarkStyle {
|
||||
MarkMode mode{}; // standard: what the player's list gives
|
||||
std::array<std::uint8_t, 3> from{}, to{}; // as picked on screen. gradient: between the two; solid: `from`
|
||||
std::uint8_t speed{}; // 0 normal, 1 slow, 2 fast
|
||||
bool operator==(const MarkStyle &) const = default;
|
||||
};
|
||||
// The marked cosmetics, in this order: whatever the skater has in each of these slots, then the
|
||||
// parts of whatever board they ride.
|
||||
inline constexpr std::array<std::string_view, 12> mark_item_names{
|
||||
"Top", "Bottoms", "Shoes", "Socks", "Hat", "Glasses", "Outfit", "Costume", "Deck", "Grip tape", "Trucks", "Wheels"};
|
||||
inline constexpr std::size_t mark_items = mark_item_names.size(), skater_mark_items = 8;
|
||||
using MarkStyles = std::array<MarkStyle, mark_items>;
|
||||
// The rainbow, as a style a developer or a member of staff picks (a developer's standard is
|
||||
// the rainbow already). Players on builds from before it could be picked must be able to read
|
||||
// the style, so it travels as one they know: a solid colour, with this in the second colour a
|
||||
// solid does not use. They show the solid colour; nobody else's item takes the rainbow from it.
|
||||
inline constexpr std::array<std::uint8_t, 3> rainbow_marker{0x52, 0x42, 0x57}; // "RBW"
|
||||
inline bool rainbow_style(const MarkStyle &style) noexcept { return style.mode == MarkMode::solid && style.to == rainbow_marker; }
|
||||
inline bool valid_mark_style(const MarkStyle &style) noexcept {
|
||||
return style.mode <= MarkMode::solid && style.speed <= 2;
|
||||
}
|
||||
struct Appearance {
|
||||
CosmeticRecipe skater, board;
|
||||
PlayerCard card;
|
||||
// The player has turned off what the ReSkate backend gives them (developer_identity.h):
|
||||
// nobody shows their tag, or nobody animates their hoodie and board.
|
||||
bool hide_tag{}, hide_items{};
|
||||
MarkStyles marks{};
|
||||
bool operator==(const Appearance &) const = default;
|
||||
};
|
||||
// Stand-ins for a peer's cosmetic that this PC does not have (a player's
|
||||
|
||||
@@ -97,6 +97,9 @@ struct Remote {
|
||||
std::atomic<std::uint64_t> native_evaluated{}, native_skipped{}, native_evaluation_us{}, apply_us{};
|
||||
ULONGLONG next_native_animation{}, next_safety_audit{}, next_entity_audit{},
|
||||
next_board_safety_audit{}, next_board_status{}, next_status{};
|
||||
// A skateboard that went away is not created again before this: one creation is a heavy
|
||||
// native pass, and a player's board may come and go with every pose.
|
||||
ULONGLONG next_board_create{};
|
||||
bool board_failed{};
|
||||
std::string board_issue;
|
||||
std::string board_status;
|
||||
|
||||
@@ -252,10 +252,12 @@ std::string show_board(std::uintptr_t base, const NativeFrame &local, const Pose
|
||||
require(!r.board_failed, r.board_issue.c_str());
|
||||
}
|
||||
if (pose.board.empty()) {
|
||||
if (r.board_entity) r.next_board_create = GetTickCount64() + 2000;
|
||||
remove_board(base);
|
||||
return "Waiting for skateboard pose.";
|
||||
}
|
||||
if (!r.board_entity) {
|
||||
if (GetTickCount64() < r.next_board_create) return "Waiting to show the skateboard again.";
|
||||
const auto local_board = read_native_board(readable, base, local.entity);
|
||||
if (!local_board.entity || local_board.entity != local.board_entity)
|
||||
return "Waiting for the local skateboard blueprint.";
|
||||
@@ -418,7 +420,7 @@ void remove_remote(std::uintptr_t base) noexcept {
|
||||
r.target = {};
|
||||
r.pose_driven.store(false, std::memory_order_release);
|
||||
r.next_native_animation = r.next_safety_audit = r.next_entity_audit = r.next_board_safety_audit =
|
||||
r.next_board_status = r.next_status = 0;
|
||||
r.next_board_status = r.next_status = r.next_board_create = 0;
|
||||
r.failed = false;
|
||||
r.issue.clear();
|
||||
r.board_failed = false;
|
||||
|
||||
@@ -314,6 +314,9 @@ void PoseBuffer::correct(Pose &pose, std::uint64_t now) const {
|
||||
}
|
||||
offset_pose(pose, offset);
|
||||
}
|
||||
bool PoseBuffer::heard_within(std::uint64_t now, std::uint64_t age) const {
|
||||
return !frames_.empty() && (now < frames_.back().arrival || now - frames_.back().arrival <= age);
|
||||
}
|
||||
bool PoseBuffer::sample_remote(std::uint64_t now, Pose &pose) {
|
||||
if (playback_at_ && (now < playback_at_ || now - playback_at_ > 200000)) {
|
||||
correction_start_ = correction_end_ = 0;
|
||||
|
||||
@@ -49,6 +49,16 @@ inline bool valid_object_chunk(const ObjectChunk &chunk) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
// What of an owner's uploaded `layout` everyone else is shown under a limit of `limit` objects
|
||||
// (0: all of it). The objects already shown (`shared`) keep their place, so placing one too
|
||||
// many never takes away one that is there; the rest fill what room is left, oldest ID first.
|
||||
inline std::vector<NetworkObject> limited_layout(std::vector<NetworkObject> layout, const std::map<std::uint64_t, NetworkObject> &shared,
|
||||
std::size_t limit) {
|
||||
if (!limit || layout.size() <= limit) return layout;
|
||||
std::stable_partition(layout.begin(), layout.end(), [&](const NetworkObject &object) { return shared.contains(object.id); });
|
||||
layout.resize(limit);
|
||||
return layout;
|
||||
}
|
||||
// Per-owner state. A multipart replacement/delta becomes visible only after all
|
||||
// parts validate. Reliable delivery keeps parts ordered; stale revisions cannot
|
||||
// resurrect deleted objects. No wire IDs are native entity handles.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::multiplayer {
|
||||
// Parties on a dedicated server (a listen host puts everyone in one party instead). The
|
||||
// server owns this book: players ask it to invite, accept, leave and so on (Packet::party),
|
||||
// and every roster carries each player's party (Member::party / party_leader / party_open).
|
||||
// Parties in a session. Whoever hosts owns this book (a dedicated server, or a lobby's host):
|
||||
// players ask it to invite, accept, leave and so on (Packet::party), and every roster carries
|
||||
// each player's party (Member::party / party_leader / party_open).
|
||||
// Party numbers are the book's own, never 0 (0 = not in a party). A party always has at
|
||||
// least two members: one left alone is dissolved.
|
||||
class PartyBook {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
#include "Extension/Multiplayer/Net/protocol.h"
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::multiplayer {
|
||||
@@ -75,7 +78,47 @@ struct PoseDelivery {
|
||||
std::uint64_t source{}, epoch{}, last_sent{};
|
||||
std::uint32_t interval_us = 50000;
|
||||
std::uint64_t next_source_time{};
|
||||
std::uint32_t by_distance{}; // what the distance alone asked for, before any crowd limit
|
||||
std::uint8_t precision{}; // how finely the source's rotations are sent to them (a dedicated server's pose_precision)
|
||||
};
|
||||
// A crowd in one place: every player there is within full-rate distance of every other, and
|
||||
// what one player is sent grows with the crowd until their connection cannot carry it. The
|
||||
// default budget is what was seen to arrive through Steam's relays (about 1 MB/s a player,
|
||||
// whatever the send rate): nobody near is slowed until about 20 players are in one place at
|
||||
// 30 TPS; past that the farthest of them are. A
|
||||
// player is sent at most `budget` poses a second: everyone at the low rate at least, the
|
||||
// nearest at the full rate with two thirds of what is left, the next nearest at the half
|
||||
// rate with the rest. These are the squared distances beyond which a player is sent at the
|
||||
// half and at the low rate; `squared` (the distances to the others) is sorted here.
|
||||
inline constexpr unsigned crowd_always_full = 8, crowd_always_half = 12;
|
||||
struct CrowdLimits {
|
||||
float half = std::numeric_limits<float>::infinity(), low = std::numeric_limits<float>::infinity();
|
||||
};
|
||||
inline CrowdLimits crowd_limits(std::span<float> squared, unsigned tps, unsigned budget = crowd_pose_budget) {
|
||||
const unsigned full = 1000000U / multiplayer_pose_interval(tps), half = 10, low = 5;
|
||||
const auto count = static_cast<unsigned>(squared.size());
|
||||
if (count * full <= budget) return {};
|
||||
std::sort(squared.begin(), squared.end());
|
||||
const unsigned spare = budget > count * low ? budget - count * low : 0;
|
||||
// Whatever the budget, the nearest few are sent at the full rate and the next few at the
|
||||
// half rate: with enough players on, the low rate for everyone is the whole budget, and
|
||||
// without this nobody at all was left at the full rate (and so nobody was heard).
|
||||
const unsigned wanted_full = spare * 2 / 3 / (full - low);
|
||||
const unsigned at_full = std::min(count, std::max(wanted_full, crowd_always_full));
|
||||
const unsigned left = spare > at_full * (full - low) ? spare - at_full * (full - low) : 0;
|
||||
const unsigned at_half = std::min(count - at_full, std::max(left / (half - low), crowd_always_half));
|
||||
CrowdLimits limits;
|
||||
// Between two players the limit falls halfway, so that neither sits on it.
|
||||
const auto after = [&](unsigned sent) {
|
||||
return sent >= count ? std::numeric_limits<float>::infinity() : sent ? (squared[sent - 1] + squared[sent]) / 2 : -1.f;
|
||||
};
|
||||
limits.half = after(at_full);
|
||||
limits.low = after(at_full + at_half);
|
||||
return limits;
|
||||
}
|
||||
inline std::uint32_t crowd_interval(std::uint32_t interval, float distance_squared, const CrowdLimits &limits) noexcept {
|
||||
return distance_squared > limits.low ? 200000U : distance_squared > limits.half ? std::max(interval, 100000U) : interval;
|
||||
}
|
||||
// Preserve the timer phase across variable client frames/packet arrivals. Only
|
||||
// the newest state is sent; elapsed slots are skipped without catch-up packets.
|
||||
inline void advance_pose_deadline(std::uint64_t &next, std::uint64_t now, std::uint64_t interval) {
|
||||
@@ -102,11 +145,74 @@ struct DirectUploadBudget {
|
||||
}
|
||||
}
|
||||
};
|
||||
// What one player's game sends of each relayed stream, with room to spare: a host passes on
|
||||
// no more than this from any one source, whatever the connection's overall budget allows.
|
||||
// Outfits: captured twice a second and sent when changed. Counted over five seconds.
|
||||
inline constexpr unsigned outfit_burst = 12;
|
||||
struct OutfitBudget {
|
||||
std::uint64_t since{};
|
||||
unsigned count{};
|
||||
bool accept(std::uint64_t now) noexcept {
|
||||
if (now < since || now - since >= 5000000) { since = now; count = 0; }
|
||||
return ++count <= outfit_burst;
|
||||
}
|
||||
};
|
||||
// Skater sound: at most one packet per network tick, a few samples each.
|
||||
struct SoundBudget {
|
||||
std::uint64_t since{};
|
||||
std::size_t packets{}, samples{};
|
||||
bool accept(std::uint64_t now, std::size_t count) noexcept {
|
||||
if (now < since || now - since >= 1000000) { since = now; packets = samples = 0; }
|
||||
if (packets >= multiplayer_tick_rates.back() + 30U || samples + count > 400) return false;
|
||||
++packets;
|
||||
samples += count;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
// A connection that never finished joining (it timed out, or had the wrong password or code)
|
||||
// may try again at once the first time. After that every failure makes its Steam ID wait
|
||||
// longer before a host takes its connection again, so one account cannot hold a player slot
|
||||
// or try passwords over and over.
|
||||
struct JoinBackoff {
|
||||
struct Entry {
|
||||
std::uint64_t until{}, last{};
|
||||
unsigned failures{};
|
||||
};
|
||||
static constexpr std::uint64_t forget_us = 30ULL * 60 * 1000000, longest_us = 10ULL * 60 * 1000000;
|
||||
std::map<std::uint64_t, Entry> entries;
|
||||
// Notes a failed attempt and returns how many this ID has made lately.
|
||||
unsigned failed(std::uint64_t id, std::uint64_t now) {
|
||||
auto &entry = entries[id];
|
||||
if (entry.last && now >= entry.last && now - entry.last > forget_us) entry.failures = 0;
|
||||
++entry.failures;
|
||||
entry.last = now;
|
||||
const auto wait = entry.failures < 2 ? 0ULL
|
||||
: std::min<std::uint64_t>(longest_us, 5000000ULL << std::min(entry.failures - 2, 8U));
|
||||
entry.until = now + wait;
|
||||
return entry.failures;
|
||||
}
|
||||
bool waiting(std::uint64_t id, std::uint64_t now) const {
|
||||
const auto found = entries.find(id);
|
||||
return found != entries.end() && now < found->second.until;
|
||||
}
|
||||
void joined(std::uint64_t id) { entries.erase(id); }
|
||||
void prune(std::uint64_t now) {
|
||||
std::erase_if(entries, [&](const auto &entry) {
|
||||
return now >= entry.second.last && now - entry.second.last > forget_us;
|
||||
});
|
||||
}
|
||||
};
|
||||
// How much one peer may send in a second. `when` is when the packet arrived (the transport's
|
||||
// TransportMessage::arrived), not when it is read: a receiver that could not read for a few
|
||||
// seconds (a server whose console held it up, a game loading a map) reads everything that
|
||||
// arrived meanwhile at once, and counted by reading time every peer looked like a flood and
|
||||
// was dropped. Packets are not always read in arrival order (Steam's lanes are read one after
|
||||
// another): one from before the second being counted is counted in it.
|
||||
struct ReceiveBudget {
|
||||
std::uint64_t since{}, bytes{}, packets{};
|
||||
bool accept(std::uint64_t now, std::size_t size, unsigned sources = 1) {
|
||||
if (now - since >= 1000000) {
|
||||
since = now;
|
||||
bool accept(std::uint64_t when, std::size_t size, unsigned sources = 1) {
|
||||
if (when > since && when - since >= 1000000) {
|
||||
since = when;
|
||||
bytes = packets = 0;
|
||||
}
|
||||
bytes += size;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "Extension/Multiplayer/Hud/native_indicators.h"
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
#include "party_book.h"
|
||||
#include "Engine/Game/World/world_names.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
||||
#include "Extension/Multiplayer/Hud/follow_camera.h"
|
||||
@@ -19,6 +20,7 @@
|
||||
#include "Extension/Skater/physics_tuning.h"
|
||||
#include "Extension/Multiplayer/Remote/remote_collision.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Text/word_filter.h"
|
||||
#include "Engine/Game/Multiplayer/session_tools.h"
|
||||
#include "Engine/Game/UI/game_view.h"
|
||||
#include <Windows.h>
|
||||
@@ -71,10 +73,14 @@ void stop(Session &s, std::string reason) {
|
||||
s.voice_policy = {};
|
||||
s.tps = multiplayer_default_tps;
|
||||
s.object_placement = ObjectPlacement::everyone;
|
||||
s.object_limit = 0;
|
||||
set_lobby_object_limit(0);
|
||||
s.server_admin = false;
|
||||
s.server_bans.clear();
|
||||
s.server_ban_total = 0;
|
||||
s.server_maps.clear();
|
||||
s.server_map_pool.clear();
|
||||
s.server_map_rotation = 0;
|
||||
set_lobby_object_placement_allowed(true);
|
||||
s.guest_noclip = s.guest_no_bail = s.guest_boosts = true;
|
||||
s.enforce_tuning = true;
|
||||
@@ -86,12 +92,19 @@ void stop(Session &s, std::string reason) {
|
||||
s.sent_tuning.reset();
|
||||
s.tuning_packet.clear();
|
||||
s.next_tuning_check = 0;
|
||||
s.host_extras.reset();
|
||||
s.sent_extras = 0;
|
||||
s.extras_packet.clear();
|
||||
s.next_extras_check = 0;
|
||||
set_session_tuning_enforced(false);
|
||||
set_host_physics_extras({});
|
||||
s.server_votes = 0;
|
||||
set_session_tools_allowed(true, true, true);
|
||||
s.object_clears.reset();
|
||||
s.clear_pending = false;
|
||||
s.force_world_layers = false;
|
||||
s.banned.clear();
|
||||
s.join_backoff = {};
|
||||
apply_host_world_layers(false, s.layers);
|
||||
s.layers = default_world_layers();
|
||||
clear_remote_network_objects();
|
||||
@@ -100,6 +113,8 @@ void stop(Session &s, std::string reason) {
|
||||
set_lobby_park_mode(false, false);
|
||||
s.parks = {}; s.next_park_update = 0;
|
||||
s.next_party_update = 0;
|
||||
s.parties = PartyBook{};
|
||||
s.parties_revision = 0;
|
||||
s.client_timing = {};
|
||||
s.next_publish = 0;
|
||||
s.last_client_log = 0;
|
||||
@@ -118,6 +133,7 @@ void stop(Session &s, std::string reason) {
|
||||
s.awaiting_map = s.join_map_authorized = s.map_load_submitted = false;
|
||||
s.join_started = s.last_map_request = s.last_map_load_check = 0;
|
||||
s.join_destination.clear();
|
||||
s.map_label.clear();
|
||||
s.world = 1;
|
||||
s.travelling = false;
|
||||
s.host_world_ready = true;
|
||||
@@ -139,6 +155,15 @@ void stop(Session &s, std::string reason) {
|
||||
s.next_send = s.last_hello = s.last_roster = s.last_cosmetic_capture = s.last_routes =
|
||||
s.last_network_log = 0;
|
||||
s.sequence = s.roster_sequence = 0;
|
||||
s.server_chat_badge = default_server_chat_badge;
|
||||
s.server_chat_text = default_server_chat_text;
|
||||
s.pose_streams.clear();
|
||||
s.pose_ack = {};
|
||||
s.pose_ack_due = false;
|
||||
s.pose_upload = {};
|
||||
s.own_poses.clear();
|
||||
s.sound_streams.clear();
|
||||
s.sound_upload = {};
|
||||
s.local_pose_count = s.logged_local_pose_count = 0;
|
||||
s.status = std::move(reason);
|
||||
}
|
||||
@@ -146,6 +171,14 @@ void stop(Session &s, std::string reason) {
|
||||
// admission, password challenges and stable member slots intact.
|
||||
void clear_world(Session &s, std::uint64_t now) {
|
||||
s.voice.reset();
|
||||
// The server starts its pose streams and message numbers over with the world.
|
||||
s.pose_streams.clear();
|
||||
s.pose_ack = {};
|
||||
s.pose_ack_due = false;
|
||||
s.pose_upload.restart();
|
||||
s.own_poses.clear();
|
||||
s.sound_streams.clear();
|
||||
s.sound_upload.restart();
|
||||
clear_remote_network_objects();
|
||||
s.object_owners_valid = false;
|
||||
s.local_objects = {}; s.next_object_update = 0;
|
||||
@@ -244,23 +277,40 @@ void publish_party(Session &s) {
|
||||
}
|
||||
if (!s.display_preferences_loaded) {
|
||||
s.display_preferences_loaded = true;
|
||||
// A lobby is one party unless the player turned that off (the older PartyOverlay
|
||||
// preference, off by default, is not carried over).
|
||||
s.party_overlay = profile_runtime::local_preference("LobbyParty").value_or(true);
|
||||
s.nametags = profile_runtime::local_preference("Nametags").value_or(true);
|
||||
s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true);
|
||||
if (const auto saved = profile_runtime::local_value("NametagDistance"); saved && saved->is_number())
|
||||
s.nametag_distance = std::clamp(saved->get<float>(), 10.f, 500.f);
|
||||
s.nametag_dots = profile_runtime::local_preference("NametagDots").value_or(true);
|
||||
s.prefer_direct = profile_runtime::local_preference("DirectConnections").value_or(true);
|
||||
if (const auto saved = profile_runtime::local_value("PlayerDistance"); saved && saved->is_number())
|
||||
s.player_distance = std::clamp(saved->get<float>(), player_distance_least, player_distance_unlimited);
|
||||
s.nametags_friends = profile_runtime::local_preference("NametagsFriendsOnly").value_or(false);
|
||||
s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true);
|
||||
s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true);
|
||||
s.chat_bubbles = profile_runtime::local_preference("ChatBubbles").value_or(true);
|
||||
s.chat_bubbles_own = profile_runtime::local_preference("ChatBubblesOwn").value_or(false);
|
||||
if (const auto saved = profile_runtime::local_value("ChatBubblesDistance"); saved && saved->is_number())
|
||||
s.chat_bubbles_distance = std::clamp(saved->get<float>(), 5.f, 500.f);
|
||||
if (const auto saved = profile_runtime::local_value("ChatBubblesDuration"); saved && saved->is_number())
|
||||
s.chat_bubbles_duration = std::clamp(saved->get<float>(), 1.f, 30.f);
|
||||
if (const auto saved = profile_runtime::local_value("ChatBubblesHistory"); saved && saved->is_number())
|
||||
s.chat_bubbles_history = std::clamp(static_cast<int>(saved->get<double>()), 1, 8);
|
||||
show_own_tag(profile_runtime::local_preference("IdentityTag").value_or(true));
|
||||
show_own_items(profile_runtime::local_preference("IdentityItems").value_or(true));
|
||||
if (const auto saved = profile_runtime::local_value("IdentityStyles"); saved && saved->is_string())
|
||||
if (const auto styles = developer_hoodie_detail::parse_mark_styles(saved->string()))
|
||||
developer_hoodie_detail::own_styles.store(*styles);
|
||||
apply_nametags(s);
|
||||
}
|
||||
// The party's limit: a lobby is one party of up to its capacity; a dedicated server's
|
||||
// parties hold up to eight (the game's Party panel rows).
|
||||
// The party's limit: parties players form hold up to eight (the game's Party panel rows),
|
||||
// in a lobby as on a dedicated server.
|
||||
const bool session = s.mode == Mode::host || s.mode == Mode::join;
|
||||
const auto capacity = s.mode == Mode::off ? static_cast<unsigned>(max_players)
|
||||
: dedicated_host(s) ? static_cast<unsigned>(PartyBook::default_limit) : s.capacity;
|
||||
// A party formed on a dedicated server is always the game's party (its Social menu, Coop
|
||||
// button, beacons...); a lobby is one too unless the player turned Lobby party off.
|
||||
set_native_party_changes(dedicated_host(s));
|
||||
const bool shown = dedicated_host(s) ? s.local_party != 0 : s.mode != Mode::off && s.party_overlay;
|
||||
: session ? static_cast<unsigned>(PartyBook::default_limit) : s.capacity;
|
||||
// A party the player formed is the game's party (its Social menu, Coop button, beacons...).
|
||||
// Nobody is in one just for being in the same lobby or on the same server.
|
||||
set_native_party_changes(session);
|
||||
const bool shown = session ? s.local_party != 0 : s.mode != Mode::off;
|
||||
update_native_party(s.base, roster, capacity, shown);
|
||||
}
|
||||
// Links the players' own throwdowns (Extension/Throwdowns/throwdown_relay.cpp). Runs
|
||||
@@ -341,12 +391,11 @@ Pose out_of_sight(Pose pose) {
|
||||
// local skater and the camera: every frame within 200 m, 20 Hz past it, 10 Hz past 350 m, and
|
||||
// every frame again once back inside 190 m. Between samples the skater keeps its pose, which
|
||||
// saves the interpolation, the pose copy and the skeleton write for players too far away to see.
|
||||
// Only distance decides. Players out of the camera's view were sampled at 20 Hz as well for a
|
||||
// while (it saved client-tick time on busy servers), but the game's replays record every skater
|
||||
// as it stood each frame and their camera looks wherever it likes afterwards: a player who had
|
||||
// been behind the camera moved at 20 Hz in the replay.
|
||||
constexpr int far_full_rate_return = 190, far_half_rate_start = 200, far_low_rate_start = 350;
|
||||
// Out of the camera's view, players past this many metres are sampled at 20 Hz too: the whole-
|
||||
// skeleton interpolation grows with the player count and was most of ReSkate's client-tick cost
|
||||
// on busy servers (profiled 2026-10-03). Nearer ones stay every frame for collision and close
|
||||
// shadows; one coming into view is sampled that frame.
|
||||
constexpr float out_of_view_full_rate = 8.0f;
|
||||
// Metres from a player to the nearer of the local skater and the camera (infinite if neither
|
||||
// is known).
|
||||
float nearest_distance(const Peer &p, const NativeFrame &local, const std::optional<GameView> &view) {
|
||||
@@ -360,23 +409,6 @@ float nearest_distance(const Peer &p, const NativeFrame &local, const std::optio
|
||||
if (view) consider(view->world[12], view->world[13], view->world[14]);
|
||||
return std::sqrt(nearest);
|
||||
}
|
||||
// Whether a player is certainly outside the camera's view: a 2 m sphere around their body past
|
||||
// the camera, or past a side, top or bottom of its view (taken as wide as a 21:9 screen).
|
||||
bool out_of_view(const Peer &p, const std::optional<GameView> &view) {
|
||||
if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return false;
|
||||
const auto &m = view->world;
|
||||
const auto &at = p.render_pose.root.position;
|
||||
const float dx = at[0] - m[12], dy = at[1] + 1.0f - m[13], dz = at[2] - m[14];
|
||||
const float depth = -(dx * m[8] + dy * m[9] + dz * m[10]);
|
||||
const float side = dx * m[0] + dy * m[1] + dz * m[2], height = dx * m[4] + dy * m[5] + dz * m[6];
|
||||
constexpr float radius = 2.0f;
|
||||
if (depth < -radius) return true;
|
||||
const float tan_v = std::tan(view->vertical_fov * 3.14159265f / 360.0f), tan_h = tan_v * (21.0f / 9.0f);
|
||||
const auto outside = [&](float offset, float tangent) {
|
||||
return std::abs(offset) - depth * tangent > radius * std::sqrt(1.0f + tangent * tangent);
|
||||
};
|
||||
return outside(side, tan_h) || outside(height, tan_v);
|
||||
}
|
||||
std::uint64_t far_sample_interval(const Peer &p, float distance) {
|
||||
const auto beyond = [&](int metres) { return distance > static_cast<float>(metres); };
|
||||
if (!std::isfinite(distance) || !beyond(far_full_rate_return)) return 0;
|
||||
@@ -387,10 +419,43 @@ std::uint64_t far_sample_interval(const Peer &p, float distance) {
|
||||
void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::uint64_t now) {
|
||||
if (!world_playing(s, local)) return;
|
||||
const auto view = latest_game_view();
|
||||
// ReSkate's nametags: every shown player, placed above their head each frame.
|
||||
// ReSkate's nametags and chat bubbles: every shown player, placed above their head
|
||||
// each frame.
|
||||
std::vector<NametagPlayer> nametags;
|
||||
const bool labels = s.nametags && s.custom_nametags;
|
||||
const bool bubbles = s.chat_bubbles;
|
||||
const bool labels = s.nametags || bubbles;
|
||||
if (labels) refresh_friends(s);
|
||||
// The newest chat lines from `sender` still inside the bubble duration, oldest first,
|
||||
// filtered the same way the chat panel filters them, each with the opacity it has left
|
||||
// (it fades over the last half second). At most `chat_bubbles_history` lines.
|
||||
const auto recent_bubbles = [&](std::uint64_t sender) {
|
||||
std::vector<NametagBubble> result;
|
||||
if (!bubbles) return result;
|
||||
const int limit = std::max(1, s.chat_bubbles_history);
|
||||
const auto duration = static_cast<std::uint64_t>(std::max(0.5f, s.chat_bubbles_duration) * 1e6f);
|
||||
const auto fade_from = std::min<std::uint64_t>(duration, 500000);
|
||||
for (auto it = s.chat.rbegin(); it != s.chat.rend() && static_cast<int>(result.size()) < limit; ++it) {
|
||||
if (it->sender != sender) continue;
|
||||
// Older lines are older still: once one has expired, stop.
|
||||
if (now < it->received || now - it->received >= duration) break;
|
||||
NametagBubble line;
|
||||
line.text = it->text;
|
||||
if (s.chat_filter) {
|
||||
const auto masked = s.chat_masked.find(it->sequence);
|
||||
auto filtered = masked != s.chat_masked.end() ? masked->second.second : text::mask_bad_words(it->text);
|
||||
if (filtered != it->text) line.raw = std::exchange(line.text, std::move(filtered));
|
||||
}
|
||||
if (line.text.find_first_not_of(' ') == std::string::npos) continue;
|
||||
const auto age = now - it->received;
|
||||
// Pops in over the first few frames, fades over the last half second.
|
||||
constexpr std::uint64_t pop_us = 220000;
|
||||
line.appear = std::min(1.0f, static_cast<float>(age) / static_cast<float>(pop_us));
|
||||
line.fade = fade_from ? std::min(1.0f, static_cast<float>(duration - age) / static_cast<float>(fade_from)) : 1.0f;
|
||||
result.push_back(std::move(line));
|
||||
}
|
||||
std::reverse(result.begin(), result.end()); // oldest first
|
||||
return result;
|
||||
};
|
||||
// Who is talking, as last published for the UI (10 Hz), without copying the voice model.
|
||||
const auto &voices = s.view.voice.players;
|
||||
const auto label = [&](const Peer &p) {
|
||||
@@ -400,7 +465,10 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
tag.name = p.member.name.empty() ? s.transport.name(p.member.id) : p.member.name;
|
||||
// The same role colour and badge the player's chat lines get.
|
||||
std::tie(tag.color, tag.tag) = player_role(s, p.member.id, false);
|
||||
// Friends only: everyone else keeps their chat bubbles and loses the name and dot.
|
||||
tag.nameless = s.nametags_friends && !std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), p.member.id);
|
||||
tag.talking = std::any_of(voices.begin(), voices.end(), [&](const auto &v) { return v.id == p.member.id && v.speaking; });
|
||||
tag.bubbles = recent_bubbles(p.member.id);
|
||||
nametags.push_back(std::move(tag));
|
||||
};
|
||||
// Creating a player's actor (skater, skateboard and both recipes) or re-applying a
|
||||
@@ -435,6 +503,24 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
p.next_audio_update = 0;
|
||||
}
|
||||
};
|
||||
// The developer hoodie and board mark an identity: only one Steam vouches for (steam_vouched).
|
||||
const auto developer_id = [&](const Peer &p) {
|
||||
return steam_vouched(s, p) && shows_items(p) ? p.member.id : std::uint64_t{};
|
||||
};
|
||||
// Only players within `player_distance` get a skater: each one costs memory and frame time
|
||||
// whether or not it is in view, and on a full server most are far across the map, too small
|
||||
// to see. Checked twice a second; a player already shown stays until a little further
|
||||
// out, so one skating along the edge is not built and taken down over and over (building
|
||||
// a skater is the expensive part). Before a pose has placed them, nobody is too far.
|
||||
if (now >= s.next_shown_rank) {
|
||||
s.next_shown_rank = now + 500000;
|
||||
const bool everyone = s.player_distance >= player_distance_unlimited;
|
||||
for (auto &p : active_peers(s)) {
|
||||
if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id)) continue;
|
||||
p.shown_wanted = everyone || !p.placed ||
|
||||
nearest_distance(p, local, view) <= s.player_distance * (p.visible ? 1.15f : 1.f);
|
||||
}
|
||||
}
|
||||
each_active_peer(s, [&](Peer &p) {
|
||||
// A dedicated server has no skater to show.
|
||||
if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id))
|
||||
@@ -442,10 +528,11 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
const bool was_visible = p.visible;
|
||||
// A far player between samples: their skater keeps its pose; sound, the label and
|
||||
// the spectate position are still refreshed (sound events are released one per frame).
|
||||
if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample &&
|
||||
!(p.far_for_view && !out_of_view(p, view))) {
|
||||
update_developer_hoodie(s.base, remote_skater_entity(), p.member.id, remote_skater_generation(), p.developer_hoodie);
|
||||
update_developer_board(s.base, remote_board_entity(), p.member.id, remote_skater_generation(), p.developer_board);
|
||||
if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample) {
|
||||
update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie,
|
||||
mark_styles(p));
|
||||
update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board,
|
||||
mark_styles(p));
|
||||
present_audio(p);
|
||||
update_party_position(&p.render_pose);
|
||||
if (labels) label(p);
|
||||
@@ -455,11 +542,20 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
if (local.ready && !p.render_failed) {
|
||||
const bool sampled = s.mode == Mode::echo ? p.poses.sample(now, p.render_pose)
|
||||
: p.poses.sample_remote(now, p.render_pose);
|
||||
// Nothing new from them for over a second (a stall here, at the host or on the way):
|
||||
// their skater stays where it was for a while longer. Taking it down and building
|
||||
// it again three seconds later is the most expensive thing a frame can do, and on
|
||||
// a busy server one slow frame made it happen to everyone at once, which made
|
||||
// the next frames slower still.
|
||||
const bool held = !sampled && was_visible && p.poses.heard_within(now, 8000000);
|
||||
// Showing a player without an actor spawns one (native_skater_spawn.cpp).
|
||||
const bool spawning = sampled && p.appearance.value() && !was_visible && !remote_skater_entity();
|
||||
if (spawning && native_pass) {
|
||||
if (sampled) p.placed = true;
|
||||
const bool spawning = sampled && p.shown_wanted && p.appearance.value() && !was_visible && !remote_skater_entity();
|
||||
if (spawning && now < p.next_spawn) {
|
||||
p.native_status = "Waiting to show the player again.";
|
||||
} else if (spawning && native_pass) {
|
||||
p.native_status = "Waiting for another player's skater to finish spawning.";
|
||||
} else if (sampled && p.appearance.value()) {
|
||||
} else if ((sampled || held) && p.shown_wanted && p.appearance.value()) {
|
||||
if (spawning) {
|
||||
native_pass = true;
|
||||
p.applied_cosmetics = 0; // the new actor wears no recipe yet
|
||||
@@ -472,9 +568,6 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
p.native_status);
|
||||
const auto distance = nearest_distance(p, local, view);
|
||||
p.far_interval = p.visible && !hidden ? far_sample_interval(p, distance) : 0;
|
||||
p.far_for_view = p.visible && !hidden && !p.far_interval && std::isfinite(distance) &&
|
||||
distance > out_of_view_full_rate && s.mode != Mode::echo && out_of_view(p, view);
|
||||
if (p.far_for_view) p.far_interval = 50000;
|
||||
p.next_far_sample = now + p.far_interval;
|
||||
// The native work a far player's skater may skip (puppet_cost.cpp).
|
||||
if (p.visible) note_remote_distance(distance);
|
||||
@@ -514,16 +607,21 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
remove_remote(s.base);
|
||||
}
|
||||
} else {
|
||||
if (was_visible)
|
||||
if (was_visible) {
|
||||
remove_remote(s.base);
|
||||
p.native_status =
|
||||
sampled ? "Waiting for the player's cosmetic recipe." : "Waiting for player poses.";
|
||||
p.next_spawn = now + 3000000;
|
||||
}
|
||||
p.native_status = !p.shown_wanted ? "Further away than players are shown."
|
||||
: sampled ? "Waiting for the player's cosmetic recipe."
|
||||
: "Waiting for player poses.";
|
||||
// No skater for them, but still their name or dot where they are.
|
||||
if (!p.shown_wanted && sampled && labels) label(p);
|
||||
}
|
||||
}
|
||||
update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, p.member.id,
|
||||
remote_skater_generation(), p.developer_hoodie);
|
||||
update_developer_board(s.base, p.visible ? remote_board_entity() : 0, p.member.id,
|
||||
remote_skater_generation(), p.developer_board);
|
||||
update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p),
|
||||
remote_skater_generation(), p.developer_hoodie, mark_styles(p));
|
||||
update_developer_board(s.base, p.visible ? remote_board_entity() : 0, developer_id(p),
|
||||
remote_skater_generation(), p.developer_board, mark_styles(p));
|
||||
if (!p.visible) {
|
||||
stop_remote_audio();
|
||||
p.presented_audio.reset();
|
||||
@@ -535,9 +633,23 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
}
|
||||
}
|
||||
});
|
||||
// The local player's own lines, above their own skater, when asked for.
|
||||
if (labels && bubbles && s.chat_bubbles_own && local.ready) {
|
||||
auto own = recent_bubbles(s.transport.status().local_id);
|
||||
if (!own.empty()) {
|
||||
NametagPlayer tag;
|
||||
tag.head = local.pose.root.position;
|
||||
tag.head[1] += 1.0f;
|
||||
tag.self = true;
|
||||
tag.bubbles = std::move(own);
|
||||
nametags.push_back(std::move(tag));
|
||||
}
|
||||
}
|
||||
if (labels)
|
||||
publish_custom_nametags(s.base, std::move(nametags),
|
||||
local.ready ? std::optional(local.pose.root.position) : std::nullopt);
|
||||
local.ready ? std::optional(local.pose.root.position) : std::nullopt,
|
||||
s.nametags, bubbles, s.chat_bubbles_distance, s.nametag_distance,
|
||||
s.nametag_dots);
|
||||
}
|
||||
} // namespace
|
||||
MultiplayerModel model() {
|
||||
@@ -545,6 +657,7 @@ MultiplayerModel model() {
|
||||
std::lock_guard lock(s.mutex);
|
||||
return s.view;
|
||||
}
|
||||
std::string take_leave_notice() { return std::exchange(session().leave_notice, {}); }
|
||||
MultiplayerChat chat() {
|
||||
auto &s = session();
|
||||
std::lock_guard lock(s.mutex);
|
||||
@@ -637,6 +750,14 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
|
||||
s.last_map_load_check = now;
|
||||
std::string detail;
|
||||
const auto result = loader(s.join_destination, s.map_load_submitted, detail);
|
||||
if (result == MapLoadResult::missing) {
|
||||
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
|
||||
const char *who = dedicated_host(s) ? "server" : "host";
|
||||
s.leave_notice = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") +
|
||||
name + ", which is not installed on this PC. Install its map mod and join again.";
|
||||
stop(s, s.leave_notice);
|
||||
return false;
|
||||
}
|
||||
if (result == MapLoadResult::failed) {
|
||||
stop(s, detail.empty() ? "The host's map could not be loaded." : std::move(detail));
|
||||
return false;
|
||||
@@ -693,6 +814,7 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi
|
||||
expire_remote_collision(base, now_us());
|
||||
if (s.mode == Mode::off) {
|
||||
physics_tuning::release(base); // the player's own tuning again after a session
|
||||
set_session_tuning_enforced(false);
|
||||
relay_throwdowns(s, ready);
|
||||
// Without a session the UI model follows at the same 10 Hz as in one;
|
||||
// commands still publish at once.
|
||||
@@ -785,6 +907,8 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi
|
||||
s.client_timing.record(ClientTiming::capture, now, receive_at);
|
||||
if (s.mode != Mode::echo)
|
||||
networking(s, local, receive_at);
|
||||
for (const auto ¬e : s.transport.take_direct_notes())
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note);
|
||||
relay_throwdowns(s, world_playing(s, local));
|
||||
// A S.K.A.T.E. player waiting for their turn stays off the board.
|
||||
update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard());
|
||||
|
||||
@@ -10,12 +10,13 @@ std::string command(std::string_view action, std::string_view argument = {}, std
|
||||
bool queue_command(std::string_view action, std::string_view argument, std::string_view password);
|
||||
// Called just before the host's validated native load is submitted, on the client thread.
|
||||
void host_map_change(std::string_view destination);
|
||||
enum class MapLoadResult { waiting, queued, failed };
|
||||
enum class MapLoadResult { waiting, queued, failed, missing }; // missing: the map is not installed
|
||||
// Runs on the client thread; only catalog-registered destinations may be queued.
|
||||
using MapLoader = MapLoadResult (*)(std::string_view map, bool submitted, std::string &detail);
|
||||
void tick(std::uintptr_t base, std::uintptr_t client, bool gameplay_ready, std::string_view map,
|
||||
MapLoader loader = nullptr);
|
||||
MultiplayerModel model();
|
||||
std::string take_leave_notice(); // why the session just ended over a missing map, once; client thread
|
||||
// The session's chat lines for the overlay. Thread-safe and cheap: callable every frame.
|
||||
MultiplayerChat chat();
|
||||
} // namespace dingosdk::multiplayer
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "session_internal.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_social.h"
|
||||
#include "Extension/Multiplayer/Hud/native_indicators.h"
|
||||
#include "Extension/Multiplayer/Hud/native_player_ui.h"
|
||||
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
||||
@@ -12,6 +13,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <charconv>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <ctime>
|
||||
|
||||
@@ -25,15 +27,23 @@ void apply_distances(Session &s, const MultiplayerDistances &distances) {
|
||||
// previous band's hysteresis or waiting for its old send deadline.
|
||||
for (auto &peer : active_peers(s)) peer.pose_delivery = {};
|
||||
}
|
||||
void apply_object_limit(Session &s, unsigned limit) {
|
||||
s.object_limit = limit;
|
||||
set_lobby_object_limit(s.mode == Mode::host || s.server_admin ? 0 : limit);
|
||||
}
|
||||
void apply_object_placement(Session &s, ObjectPlacement policy) {
|
||||
s.object_placement = policy;
|
||||
// On a dedicated server "host only" means its admins.
|
||||
set_lobby_object_placement_allowed(object_placement_allowed(policy, s.mode == Mode::host || s.server_admin));
|
||||
}
|
||||
void apply_nametags(const Session &s) {
|
||||
set_custom_nametags_enabled(s.nametags && s.custom_nametags);
|
||||
set_native_nametags_enabled(s.nametags && !s.custom_nametags);
|
||||
set_native_compass_enabled(!(s.nametags && s.custom_nametags));
|
||||
// The feed also runs when only chat bubbles are on: the overlay draws whichever of the
|
||||
// two is enabled from the flags it is handed.
|
||||
// Nametags are ReSkate's own (Hud/custom_nametags.h); the game's are never shown. Its
|
||||
// compass comes back when they are off, since they are what points at the other players.
|
||||
set_custom_nametags_enabled(s.nametags || s.chat_bubbles);
|
||||
set_native_nametags_enabled(false);
|
||||
set_native_compass_enabled(!s.nametags);
|
||||
}
|
||||
void apply_guest_tools(Session &s, bool noclip, bool no_bail, bool boosts) {
|
||||
s.guest_noclip = noclip;
|
||||
@@ -113,9 +123,8 @@ std::string party_command(Session &s, std::string_view argument) {
|
||||
return p ? peer_name(s, *p) : s.transport.name(id);
|
||||
};
|
||||
if (verb.empty() || verb == "status" || verb == "list") {
|
||||
if (!s.local_party) return dedicated_host(s) ? "You're not in a party. Invite a player from their player card or with: party invite <name>."
|
||||
: "You're the only one in this lobby.";
|
||||
std::string text = std::string(dedicated_host(s) ? "Your party" : "Your lobby's party") + (s.local_party_open ? " (open):" : ":");
|
||||
if (!s.local_party) return "You're not in a party. Invite a player from their player card or with: party invite <name>.";
|
||||
std::string text = std::string("Your party") + (s.local_party_open ? " (open):" : ":");
|
||||
const auto local = s.transport.status().local_id;
|
||||
text += " " + s.transport.name(local) + (s.local_party_leader ? " (leader)" : "");
|
||||
for (const auto &peer : active_peers(s))
|
||||
@@ -123,7 +132,6 @@ std::string party_command(Session &s, std::string_view argument) {
|
||||
text += ", " + peer_name(s, peer) + (peer.member.party_leader ? " (leader)" : "");
|
||||
return text;
|
||||
}
|
||||
if (!dedicated_host(s)) return "Everyone in a lobby is in one party with the host.";
|
||||
static constexpr std::pair<std::string_view, PartyAction> verbs[] = {
|
||||
{"invite", PartyAction::invite}, {"accept", PartyAction::accept}, {"decline", PartyAction::decline},
|
||||
{"join", PartyAction::join}, {"leave", PartyAction::leave}, {"kick", PartyAction::kick},
|
||||
@@ -286,6 +294,21 @@ std::string edit_object_placement(Session &s, std::string_view argument) {
|
||||
: *policy == ObjectPlacement::host_only ? "Only you can place objects. Guests' objects are frozen."
|
||||
: "Object placement is disabled for everyone. Existing objects stay.";
|
||||
}
|
||||
std::string edit_object_limit(Session &s, std::string_view argument) {
|
||||
if (s.mode != Mode::host) return "Only the session host can change the object limit.";
|
||||
const auto limit = parse_object_limit(argument);
|
||||
if (!limit) return "Use a number of objects from 1 to " + std::to_string(max_object_limit) + ", or off.";
|
||||
// Guests' games stop them at the limit when the roster arrives; enforcement is the host
|
||||
// showing everyone no more than that of each guest's layout (publish_guest_objects).
|
||||
apply_object_limit(s, *limit);
|
||||
for (auto &peer : active_peers(s)) peer.shared_from = 0; // look at every layout again
|
||||
s.roster_dirty = true;
|
||||
load_host_preferences(s);
|
||||
s.host_preferences.object_limit = *limit;
|
||||
save_host_preferences(s);
|
||||
return *limit ? "Each guest can place up to " + std::to_string(*limit) + " objects."
|
||||
: std::string("Guests can place as many objects as they like.");
|
||||
}
|
||||
// Removes every guest's objects for everyone, whatever the placement policy.
|
||||
// The host's own objects are its saved park and are left alone.
|
||||
std::string clear_guest_objects(Session &s, std::string_view) {
|
||||
@@ -307,16 +330,6 @@ std::string clear_guest_objects(Session &s, std::string_view) {
|
||||
return removed ? "Deleted " + std::to_string(removed) + " guest object" + (removed == 1 ? "" : "s") + "."
|
||||
: "Guests have no placed objects to delete.";
|
||||
}
|
||||
std::string edit_party_overlay(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.party_overlay);
|
||||
if (!enabled) return "Use on, off, or toggle for the lobby party.";
|
||||
s.party_overlay = *enabled;
|
||||
s.display_preferences_loaded = true;
|
||||
profile_runtime::set_local_preference("LobbyParty", s.party_overlay);
|
||||
s.roster_dirty = true;
|
||||
return s.party_overlay ? "Lobby party on: everyone in a lobby is in your game's party."
|
||||
: "Lobby party off: lobby players are not shown as your game's party.";
|
||||
}
|
||||
std::string edit_nametags(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.nametags);
|
||||
if (!enabled) return "Use on, off, or toggle for peer nametags.";
|
||||
@@ -326,15 +339,51 @@ std::string edit_nametags(Session &s, std::string_view argument) {
|
||||
profile_runtime::set_local_preference("Nametags", s.nametags);
|
||||
return s.nametags ? "Peer nametags shown." : "Peer nametags hidden.";
|
||||
}
|
||||
std::string edit_nametag_style(Session &s, std::string_view argument) {
|
||||
const auto custom = argument == "reskate" ? std::optional<bool>(true) : argument == "game" ? std::optional<bool>(false)
|
||||
: parse_switch(argument, s.custom_nametags);
|
||||
if (!custom) return "Use reskate, game, or toggle for the nametag style.";
|
||||
s.custom_nametags = *custom;
|
||||
s.display_preferences_loaded = true;
|
||||
apply_nametags(s);
|
||||
profile_runtime::set_local_preference("CustomNametags", s.custom_nametags);
|
||||
return s.custom_nametags ? "ReSkate nametags: names, distances and dots." : "The game's own nametags.";
|
||||
// The Special page: a player on one of the backend's lists going without their tag, or
|
||||
// without the animation on their items. Their next appearance packet tells everyone
|
||||
// (session_send.cpp).
|
||||
std::string edit_own_tag(Session &, std::string_view argument) {
|
||||
const auto shown = parse_switch(argument, own_tag_shown());
|
||||
if (!shown) return "Use on, off, or toggle for your tag.";
|
||||
show_own_tag(*shown);
|
||||
profile_runtime::set_local_preference("IdentityTag", *shown);
|
||||
return *shown ? "Your tag shows." : "Your tag is hidden, for you and everyone you skate with.";
|
||||
}
|
||||
std::string edit_own_items(Session &, std::string_view argument) {
|
||||
const auto shown = parse_switch(argument, own_items_shown());
|
||||
if (!shown) return "Use on, off, or toggle for your items.";
|
||||
show_own_items(*shown);
|
||||
profile_runtime::set_local_preference("IdentityItems", *shown);
|
||||
return *shown ? "Your items animate." : "Your items are plain, for you and everyone you skate with.";
|
||||
}
|
||||
// The Special page: how one of the player's marked cosmetics is coloured, as
|
||||
// "<cosmetic> <mode> <rrggbb> <rrggbb> <speed>" (MarkStyle). It goes out with their next
|
||||
// appearance packet and is kept in their profile.
|
||||
std::string edit_mark_style(Session &, std::string_view argument) {
|
||||
std::istringstream in{std::string(argument)};
|
||||
unsigned item{}, mode{}, from{}, to{}, speed{};
|
||||
if (!(in >> item >> mode >> std::hex >> from >> to >> std::dec >> speed) || item >= mark_items || mode > 4 ||
|
||||
from > 0xffffff || to > 0xffffff || speed > 2)
|
||||
return "That is not a cosmetic style.";
|
||||
// Mode 4, the rainbow, is the staff's to pick (a developer's standard is it already). It is
|
||||
// kept, and sent, as a solid colour older builds can show (rainbow_style).
|
||||
const auto social = steam_social_snapshot();
|
||||
const auto mark = social ? identity_mark(social->local.id) : std::nullopt;
|
||||
if (mode == 4 && mark != IdentityList::staff) return "The rainbow is not one of your styles.";
|
||||
const auto colour = [](unsigned value) {
|
||||
return std::array<std::uint8_t, 3>{static_cast<std::uint8_t>(value >> 16), static_cast<std::uint8_t>(value >> 8),
|
||||
static_cast<std::uint8_t>(value)};
|
||||
};
|
||||
auto styles = developer_hoodie_detail::own_styles.load();
|
||||
styles[item] = {static_cast<MarkMode>(mode == 4 ? 3 : mode), colour(from), colour(to), static_cast<std::uint8_t>(speed)};
|
||||
if (mode == 4) styles[item].from = developer_hoodie_detail::standard_picks(*mark).first, styles[item].to = rainbow_marker;
|
||||
// A solid colour whose unused second colour happens to be the rainbow's marker is not one.
|
||||
else if (rainbow_style(styles[item])) styles[item].to[2] ^= 1;
|
||||
developer_hoodie_detail::own_styles.store(styles);
|
||||
profile_runtime::set_local_values({{"IdentityStyles", Json(developer_hoodie_detail::mark_styles_text(styles))}});
|
||||
return std::string(mark_item_names[item]) +
|
||||
(mode == 1 ? ": off." : mode == 2 ? ": your gradient." : mode == 3 ? ": your color." : mode == 4 ? ": rainbow."
|
||||
: ": back to the usual.");
|
||||
}
|
||||
// Hidden chat still receives lines, so showing it again brings back the conversation.
|
||||
std::string edit_chat_visible(Session &s, std::string_view argument) {
|
||||
@@ -353,6 +402,114 @@ std::string edit_chat_filter(Session &s, std::string_view argument) {
|
||||
publish_chat(s);
|
||||
return s.chat_filter ? "Bad words in chat are hidden." : "Chat is shown unfiltered.";
|
||||
}
|
||||
std::string edit_player_distance(Session &s, std::string_view argument) {
|
||||
float value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !std::isfinite(value) ||
|
||||
value < player_distance_least || value > player_distance_unlimited)
|
||||
return "Player distance is a number of metres from 50 to 1000 (1000: every player).";
|
||||
s.player_distance = value;
|
||||
s.next_shown_rank = 0;
|
||||
profile_runtime::set_local_values({{"PlayerDistance", static_cast<double>(value)}});
|
||||
return value >= player_distance_unlimited ? std::string("Every player is shown as a skater, however far.")
|
||||
: "Players within " + std::to_string(static_cast<int>(value)) + " m are shown as skaters.";
|
||||
}
|
||||
std::string start_pose_dump(Session &s, std::string_view argument) {
|
||||
int seconds{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), seconds);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || seconds < 1 || seconds > 600)
|
||||
return "pose-dump <seconds> (1-600): records your own poses while in a session.";
|
||||
std::error_code error;
|
||||
const auto folder = std::filesystem::current_path(error) / "logs";
|
||||
std::filesystem::create_directories(folder, error);
|
||||
const auto file = folder / ("poses-" + std::to_string(now_us() / 1000000) + ".bin");
|
||||
s.pose_dump.close();
|
||||
s.pose_dump.clear();
|
||||
s.pose_dump.open(file, std::ios::binary | std::ios::trunc);
|
||||
if (!s.pose_dump) return "Could not write " + file.string() + ".";
|
||||
s.pose_dump.write("RSPD1\n", 6);
|
||||
s.pose_dump_until = now_us() + static_cast<std::uint64_t>(seconds) * 1000000;
|
||||
s.pose_dump_count = 0;
|
||||
return "Recording your poses for " + std::to_string(seconds) + " s to " + file.string() + ". Skate as you normally would.";
|
||||
}
|
||||
std::string edit_direct_connections(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.prefer_direct);
|
||||
if (!enabled) return "Use on, off, or toggle for direct connections.";
|
||||
s.prefer_direct = *enabled;
|
||||
profile_runtime::set_local_preference("DirectConnections", s.prefer_direct);
|
||||
return s.prefer_direct ? "Servers that offer it are connected to directly (from the next join)."
|
||||
: "Every server is reached through Steam's relays (from the next join).";
|
||||
}
|
||||
std::string edit_nametag_distance(Session &s, std::string_view argument) {
|
||||
float value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !std::isfinite(value) ||
|
||||
value < 10.f || value > 500.f)
|
||||
return "Nametag distance is a number of metres from 10 to 500.";
|
||||
s.nametag_distance = value;
|
||||
profile_runtime::set_local_values({{"NametagDistance", static_cast<double>(value)}});
|
||||
return "Names show within " + std::to_string(static_cast<int>(value)) + " m.";
|
||||
}
|
||||
std::string edit_nametag_dots(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.nametag_dots);
|
||||
if (!enabled) return "Use on, off, or toggle for nametag dots.";
|
||||
s.nametag_dots = *enabled;
|
||||
profile_runtime::set_local_preference("NametagDots", s.nametag_dots);
|
||||
return s.nametag_dots ? "Far and off-screen players show as dots." : "No dots for far and off-screen players.";
|
||||
}
|
||||
std::string edit_nametags_friends(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.nametags_friends);
|
||||
if (!enabled) return "Use on, off, or toggle for friends-only nametags.";
|
||||
s.nametags_friends = *enabled;
|
||||
profile_runtime::set_local_preference("NametagsFriendsOnly", s.nametags_friends);
|
||||
return s.nametags_friends ? "Only your Steam friends have nametags." : "Every player has a nametag.";
|
||||
}
|
||||
std::string edit_chat_bubbles(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.chat_bubbles);
|
||||
if (!enabled) return "Use on, off, or toggle for chat bubbles.";
|
||||
s.chat_bubbles = *enabled;
|
||||
s.display_preferences_loaded = true;
|
||||
apply_nametags(s);
|
||||
profile_runtime::set_local_preference("ChatBubbles", s.chat_bubbles);
|
||||
return s.chat_bubbles ? "Chat bubbles show above skaters." : "Chat bubbles hidden.";
|
||||
}
|
||||
std::string edit_chat_bubbles_own(Session &s, std::string_view argument) {
|
||||
const auto enabled = parse_switch(argument, s.chat_bubbles_own);
|
||||
if (!enabled) return "Use on, off, or toggle for your own chat bubbles.";
|
||||
s.chat_bubbles_own = *enabled;
|
||||
profile_runtime::set_local_preference("ChatBubblesOwn", s.chat_bubbles_own);
|
||||
return s.chat_bubbles_own ? "Your own messages show as bubbles too."
|
||||
: "Only other players' messages show as bubbles.";
|
||||
}
|
||||
std::string edit_chat_bubbles_distance(Session &s, std::string_view argument) {
|
||||
float value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !std::isfinite(value) ||
|
||||
value < 5.f || value > 500.f)
|
||||
return "Chat bubble distance is a number of metres from 5 to 500.";
|
||||
s.chat_bubbles_distance = value;
|
||||
profile_runtime::set_local_values({{"ChatBubblesDistance", static_cast<double>(value)}});
|
||||
return "Chat bubbles show within " + std::to_string(static_cast<int>(value)) + " m.";
|
||||
}
|
||||
std::string edit_chat_bubbles_duration(Session &s, std::string_view argument) {
|
||||
float value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !std::isfinite(value) ||
|
||||
value < 1.f || value > 30.f)
|
||||
return "Chat bubble duration is a number of seconds from 1 to 30.";
|
||||
s.chat_bubbles_duration = value;
|
||||
profile_runtime::set_local_values({{"ChatBubblesDuration", static_cast<double>(value)}});
|
||||
return "Chat bubbles last " + std::to_string(static_cast<int>(value)) + " seconds.";
|
||||
}
|
||||
std::string edit_chat_bubbles_history(Session &s, std::string_view argument) {
|
||||
int value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || value < 1 || value > 8)
|
||||
return "Chat bubble history is a number of lines from 1 to 8.";
|
||||
s.chat_bubbles_history = value;
|
||||
profile_runtime::set_local_values({{"ChatBubblesHistory", static_cast<std::int64_t>(value)}});
|
||||
return "Chat bubbles stack up to " + std::to_string(value) + " recent line(s).";
|
||||
}
|
||||
std::string kick_player(Session &s, std::string_view argument) {
|
||||
if (s.mode != Mode::host) return "Only the session host can kick players.";
|
||||
const auto split = argument.find(' ');
|
||||
@@ -448,16 +605,25 @@ std::string send_admin(Session &s, std::string text) {
|
||||
if (!send_packet(s, s.host_id, request, true, false)) return "Could not reach the server.";
|
||||
return "Sent to the server.";
|
||||
}
|
||||
// The Special page's commands. They are a player's own offline as well, where their hoodie and
|
||||
// board still animate.
|
||||
bool own_mark_command(std::string_view action) {
|
||||
return action == "mark-tag" || action == "mark-items" || action == "mark-style";
|
||||
}
|
||||
} // namespace
|
||||
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
|
||||
if (launcher::offline_mode()) return false;
|
||||
if (launcher::offline_mode() && !own_mark_command(action)) return false;
|
||||
if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" &&
|
||||
action != "distances" && action != "object-placement" && action != "kick" && action != "clear-objects" &&
|
||||
action != "party-overlay" && action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" &&
|
||||
action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" &&
|
||||
action != "nametags" && action != "chat-visible" && action != "chat-filter" &&
|
||||
action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "direct-connections" && action != "pose-dump" &&
|
||||
action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" &&
|
||||
action != "chat-bubbles-duration" && action != "chat-bubbles-history" &&
|
||||
!own_mark_command(action) &&
|
||||
action != "voice" && action != "voice-mute" &&
|
||||
action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "ban" && action != "unban" &&
|
||||
action != "world-layer-sync" && action != "noclip-allow" && action != "nobail-allow" && action != "boosts-allow" &&
|
||||
action != "tp" &&
|
||||
action != "tuning-enforce" && action != "tp" &&
|
||||
action != "tpall" && action != "tphere" && action != "browse" &&
|
||||
action != "server" && action != "party") ||
|
||||
argument.size() > (action == "host" || action == "host-config" ? 160U : action == "chat" ? 4 * multiplayer_chat_max_bytes
|
||||
@@ -477,7 +643,8 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri
|
||||
return true;
|
||||
}
|
||||
std::string command(std::string_view action, std::string_view argument, std::string_view password) {
|
||||
if (launcher::offline_mode()) return "Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate.";
|
||||
if (launcher::offline_mode() && !own_mark_command(action))
|
||||
return "Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate.";
|
||||
const bool configured_host = action == "host-config";
|
||||
if (configured_host) action = "host";
|
||||
PrivateRequest input;
|
||||
@@ -488,7 +655,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
// their own: the same menu actions, sent to the server. "server" sends
|
||||
// any server console command.
|
||||
if (dedicated_host(s) && (action == "server" || (s.server_admin &&
|
||||
(action == "distances" || action == "object-placement" || action == "voice-allow" || action == "voice-range" ||
|
||||
(action == "distances" || action == "object-placement" || action == "object-limit" || action == "voice-allow" || action == "voice-range" ||
|
||||
action == "clear-objects" || action == "kick" || action == "ban" || action == "unban" ||
|
||||
action == "world-layer-sync" || action == "noclip-allow" || action == "nobail-allow" ||
|
||||
action == "tpall" || action == "tphere" || action == "boosts-allow" || action == "tuning-enforce")))) {
|
||||
@@ -513,8 +680,8 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
if (!result.empty()) add_chat(s, 0, "ReSkate", result);
|
||||
return result;
|
||||
} else if (!dedicated_host(s) && verb == "p") {
|
||||
// In a lobby everyone is in the one party: party chat is chat.
|
||||
const auto refused = send_chat(s, rest);
|
||||
// A lobby's host relays party chat, as a dedicated server does.
|
||||
const auto refused = send_party_chat(s, rest);
|
||||
if (!refused.empty() && (s.mode == Mode::host || s.mode == Mode::join)) add_chat(s, 0, "ReSkate", refused);
|
||||
return refused.empty() ? "Message sent." : refused;
|
||||
} else if (!dedicated_host(s) && verb == "party") {
|
||||
@@ -623,16 +790,22 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
// Host-only settings check the mode themselves; guests get a refusal.
|
||||
using Setting = std::string (*)(Session &, std::string_view);
|
||||
static constexpr std::pair<std::string_view, Setting> settings[] = {
|
||||
{"object-placement", edit_object_placement}, {"kick", kick_player},
|
||||
{"object-placement", edit_object_placement}, {"object-limit", edit_object_limit}, {"kick", kick_player},
|
||||
{"noclip-allow", edit_guest_noclip}, {"nobail-allow", edit_guest_no_bail},
|
||||
{"boosts-allow", edit_guest_boosts}, {"tuning-enforce", edit_enforce_tuning},
|
||||
{"score-check", edit_score_check},
|
||||
{"ban", ban_player}, {"unban", unban_player},
|
||||
{"clear-objects", clear_guest_objects},
|
||||
{"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances},
|
||||
{"party-overlay", edit_party_overlay}, {"nametags", edit_nametags},
|
||||
{"nametag-style", edit_nametag_style},
|
||||
{"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}};
|
||||
{"nametags", edit_nametags},
|
||||
{"nametag-distance", edit_nametag_distance}, {"nametag-dots", edit_nametag_dots},
|
||||
{"nametags-friends", edit_nametags_friends}, {"player-distance", edit_player_distance}, {"direct-connections", edit_direct_connections}, {"pose-dump", start_pose_dump},
|
||||
{"mark-tag", edit_own_tag}, {"mark-items", edit_own_items}, {"mark-style", edit_mark_style},
|
||||
{"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter},
|
||||
{"chat-bubbles", edit_chat_bubbles}, {"chat-bubbles-own", edit_chat_bubbles_own},
|
||||
{"chat-bubbles-distance", edit_chat_bubbles_distance},
|
||||
{"chat-bubbles-duration", edit_chat_bubbles_duration},
|
||||
{"chat-bubbles-history", edit_chat_bubbles_history}};
|
||||
for (const auto &[name, edit] : settings)
|
||||
if (action == name) {
|
||||
s.status = edit(s, argument);
|
||||
@@ -671,8 +844,12 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
return s.lobbies.status().browser;
|
||||
}
|
||||
if (action == "join-lobby" || action == "join-friend-lobby") {
|
||||
if (s.mode != Mode::off) {
|
||||
s.status = "Leave your current session before joining another lobby.";
|
||||
// A guest can hop straight to a server or lobby they pick in the browser: they leave
|
||||
// this one first. A host ends everyone's session by leaving, so they end it themselves;
|
||||
// and a Steam friend's join offer, which can arrive unasked, never replaces a session.
|
||||
if (s.mode != Mode::off && (s.mode != Mode::join || action != "join-lobby")) {
|
||||
s.status = s.mode == Mode::join ? "Leave your current session before joining another lobby."
|
||||
: "End your session before joining another lobby.";
|
||||
publish(s);
|
||||
return s.status;
|
||||
}
|
||||
@@ -682,10 +859,27 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
const auto result = std::from_chars(argument.data(), argument.data() + argument.size(), id);
|
||||
if (result.ec != std::errc{} || result.ptr != argument.data() + argument.size() || !id)
|
||||
return "Invalid lobby selection. Refresh the browser.";
|
||||
// A dedicated server's row carries its join code.
|
||||
if (s.mode == Mode::join) {
|
||||
if (id == s.joined_public_lobby) return "You are already there.";
|
||||
stop(s, "Switching servers...");
|
||||
}
|
||||
// A dedicated server's row carries its join code. It is a public listing like a
|
||||
// lobby's: friends are told the player is there, and can follow them in.
|
||||
if (const auto *server = s.servers.find(id)) {
|
||||
const auto code = server->code;
|
||||
return command("join", code, input.password);
|
||||
auto reply = command("join", code, input.password);
|
||||
if (s.mode == Mode::join) {
|
||||
s.joined_public_lobby = id;
|
||||
publish(s);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
// A friend on a server this game has not listed yet: only the list has its code.
|
||||
if (game_server_steam_id(id)) {
|
||||
s.servers.refresh(now_us());
|
||||
s.status = "That server is not in your server list yet. Open the server browser and join it there.";
|
||||
publish(s);
|
||||
return s.status;
|
||||
}
|
||||
if (!s.transport.open())
|
||||
return s.transport.status().detail;
|
||||
@@ -717,6 +911,8 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
publish(s);
|
||||
return s.status;
|
||||
};
|
||||
// Joining is for the host or the server to refuse (a server may opt out of the bans).
|
||||
if (action == "host" && reskate_banned(s.transport.status().local_id)) return refuse(std::string(banned_notice));
|
||||
if (action == "host") {
|
||||
const auto space = argument.find(' ');
|
||||
if (space != std::string_view::npos) {
|
||||
@@ -755,6 +951,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
invitation = parse_invite(argument);
|
||||
if (!invitation)
|
||||
return refuse("Invalid join code. Paste the complete SteamID-session code from the host.");
|
||||
if (blocked_server(invitation->steam_id)) return refuse(std::string(blocked_server_notice));
|
||||
}
|
||||
stop(s, "Starting multiplayer...");
|
||||
s.chat.clear(); // A new session starts with an empty chat.
|
||||
@@ -797,6 +994,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
apply_distances(s, remembered.distances);
|
||||
s.voice_range = remembered.voice_range;
|
||||
apply_object_placement(s, remembered.placement);
|
||||
apply_object_limit(s, remembered.object_limit);
|
||||
apply_guest_tools(s, remembered.guest_noclip, remembered.guest_no_bail, remembered.guest_boosts);
|
||||
s.enforce_tuning = remembered.enforce_tuning;
|
||||
s.score_check = remembered.score_check;
|
||||
@@ -808,7 +1006,22 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
save_host_preferences(s);
|
||||
s.status = "Hosting for up to " + std::to_string(capacity) + " players at " + std::to_string(s.tps) + " TPS.";
|
||||
} else {
|
||||
if (!s.transport.join(invitation->steam_id)) {
|
||||
// A server that listens on its own address is connected to directly, with Steam's
|
||||
// relays as the fallback (steam_transport.h). The server sees the address of a
|
||||
// player who connects this way, as any dedicated server does, so the player may
|
||||
// turn it off (direct-connections). Preferences load with the first session frame,
|
||||
// so read this one here.
|
||||
const bool direct_allowed = s.display_preferences_loaded
|
||||
? s.prefer_direct
|
||||
: profile_runtime::local_preference("DirectConnections").value_or(true);
|
||||
std::uint32_t direct_ip{};
|
||||
std::uint16_t direct_port{};
|
||||
if (const auto *listed = s.servers.find(invitation->steam_id);
|
||||
listed && direct_allowed && listed->direct_ip && listed->direct_port > 0) {
|
||||
direct_ip = listed->direct_ip;
|
||||
direct_port = static_cast<std::uint16_t>(listed->direct_port);
|
||||
}
|
||||
if (!s.transport.join(invitation->steam_id, direct_ip, direct_port)) {
|
||||
s.status = s.transport.status().detail;
|
||||
publish(s);
|
||||
return s.status;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Multiplayer/chat_rate.h"
|
||||
#include "session.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include "Extension/Customization/developer_hoodie.h"
|
||||
#include "Extension/Customization/developer_board.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
@@ -10,15 +11,20 @@
|
||||
#include "Extension/Multiplayer/Steam/steam_lobbies.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_server_browser.h"
|
||||
#include "room.h"
|
||||
#include "party_book.h"
|
||||
#include "Extension/Multiplayer/Net/delta_codec.h"
|
||||
#include "Extension/Multiplayer/Net/pose_batch.h"
|
||||
#include "Extension/Multiplayer/Net/sound_codec.h"
|
||||
#include "password.h"
|
||||
#include "client_timing.h"
|
||||
#include "monotonic_clock.h"
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -43,7 +49,8 @@ struct ChatBudget {
|
||||
};
|
||||
// Throwdown messages per sender per 5 s: offers, joins, starts and ~4 scores a second.
|
||||
inline constexpr unsigned throwdown_burst = 60;
|
||||
// The party number of a listen host's lobby, which is always one party.
|
||||
// The party the echo test shows its mirrored player in (a real lobby has none until its
|
||||
// players form one).
|
||||
inline constexpr std::uint32_t lobby_party = 1;
|
||||
struct Peer {
|
||||
// Guests: this owner's layout as relayed by the host. Host: the owner's own
|
||||
@@ -86,14 +93,17 @@ struct Peer {
|
||||
// One entry per pose source relayed or sent to this recipient (at most max_players).
|
||||
std::vector<PoseDelivery> pose_delivery;
|
||||
std::uint64_t connected_at{}, last_packet{}, last_cosmetic_apply{};
|
||||
// A skater removed for lack of poses is not spawned again before this: a spawn is a heavy
|
||||
// native pass, and a player's poses may stop and start again and again.
|
||||
std::uint64_t next_spawn{};
|
||||
// Near enough to be shown as a skater; and whether a pose has placed them yet.
|
||||
bool shown_wanted = true, placed{};
|
||||
ReceiveBudget budget;
|
||||
PoseBuffer poses;
|
||||
Pose render_pose;
|
||||
// Far from the local skater and the camera, or out of the camera's view, render_pose is
|
||||
// sampled every far_interval (0: every frame) and the skater keeps it in between.
|
||||
// Far from the local skater and the camera, render_pose is sampled every far_interval
|
||||
// (0: every frame) and the skater keeps it in between.
|
||||
std::uint64_t far_interval{}, next_far_sample{};
|
||||
// far_interval is only for being out of view: coming into view samples at once.
|
||||
bool far_for_view{};
|
||||
AppearanceBuffer appearance;
|
||||
// Bumped per accepted outfit; render compares it with the one its actor wears
|
||||
// (0 after a spawn) to spread native recipe applies over frames.
|
||||
@@ -104,8 +114,18 @@ struct Peer {
|
||||
std::uint32_t voice_sequence{};
|
||||
bool received_voice{};
|
||||
VoiceBudget voice_budget;
|
||||
OutfitBudget outfit_budget;
|
||||
SoundBudget sound_budget;
|
||||
ChatRate chat_rate;
|
||||
ChatBudget throwdown_budget;
|
||||
// Chat proofs (session_receive.cpp): the copies of their lines this player sent us over
|
||||
// their own Steam connection, and the lines the host passed on as theirs that wait for one.
|
||||
struct ChatProof {
|
||||
std::uint32_t sequence{};
|
||||
std::string text;
|
||||
std::uint64_t at{};
|
||||
};
|
||||
std::vector<ChatProof> chat_proofs, chat_waiting;
|
||||
ChatBudget throwdown_budget, party_budget;
|
||||
// Host: how the guest's mods change trick scoring, as they reported it (Engine/Vfs/mod_scoring.h):
|
||||
// nothing until the report arrives, 0 for the game's own. member.scoring is the verdict.
|
||||
std::optional<std::uint64_t> scoring;
|
||||
@@ -133,6 +153,7 @@ struct Session {
|
||||
std::uint64_t next_publish{}, last_client_log{}, next_party_update{};
|
||||
MultiplayerDistances distances;
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
unsigned object_limit{}; // objects each player may have placed; 0: no limit
|
||||
// What guests may use: the host's choice, or the host's roster for a guest.
|
||||
bool guest_noclip = true, guest_no_bail = true, guest_boosts = true;
|
||||
// Host: guests skate with its physics tuning. Guest: the host's roster says so (a
|
||||
@@ -154,6 +175,13 @@ struct Session {
|
||||
std::optional<std::vector<std::uint8_t>> sent_tuning;
|
||||
std::vector<std::uint8_t> tuning_packet;
|
||||
std::uint64_t next_tuning_check{};
|
||||
// The physics the tuning does not carry (Engine/Game/Multiplayer/session_physics.h).
|
||||
// Guest: the host's, once it sent them. Host: which of its own were last sent, the packet
|
||||
// that carried them (for players who join later) and when to look again.
|
||||
std::optional<std::vector<std::uint8_t>> host_extras;
|
||||
std::uint64_t sent_extras{};
|
||||
std::vector<std::uint8_t> extras_packet;
|
||||
std::uint64_t next_extras_check{};
|
||||
std::uint8_t server_votes{}; // guest of a dedicated server: the votes it runs
|
||||
// Host: bumped per "delete all guest objects". Guest: the last value seen
|
||||
// (unset until the first roster) and whether a local wipe is outstanding.
|
||||
@@ -162,6 +190,8 @@ struct Session {
|
||||
bool force_world_layers{};
|
||||
// Players the host kicked. They cannot reconnect until the session ends.
|
||||
std::set<std::uint64_t> banned;
|
||||
// Host: Steam IDs whose attempts to join keep failing wait longer each time (room.h).
|
||||
JoinBackoff join_backoff;
|
||||
// Players banned for good (every session this PC hosts), from the local profile.
|
||||
std::vector<MultiplayerBan> bans;
|
||||
bool bans_loaded{};
|
||||
@@ -182,10 +212,16 @@ struct Session {
|
||||
// and the server's voice range from it.
|
||||
bool server_admin{};
|
||||
float roster_voice_range = default_voice_range;
|
||||
// The colours of the dedicated server's own chat lines, as its roster gives them.
|
||||
std::uint32_t server_chat_badge = default_server_chat_badge, server_chat_text = default_server_chat_text;
|
||||
// The server's ban list, as sent to us while we are one of its admins.
|
||||
std::vector<MultiplayerBan> server_bans;
|
||||
std::uint32_t server_ban_total{};
|
||||
std::vector<std::string> server_maps; // level assets the dedicated server allows
|
||||
std::vector<std::string> server_map_pool; // admins: the pool's assets in rotation order (empty: every map)
|
||||
unsigned server_map_rotation{}; // minutes per map (0: off)
|
||||
std::string map_label; // the host's name for join_destination (may be empty)
|
||||
std::string leave_notice; // take_leave_notice()
|
||||
std::uint64_t joined_public_lobby{};
|
||||
std::array<Peer, max_remote_players> peers;
|
||||
// Players take the lowest free slots, so every one sits below this mark
|
||||
@@ -200,9 +236,35 @@ struct Session {
|
||||
bool travelling{}, host_world_ready = true;
|
||||
unsigned capacity = max_players;
|
||||
// Local display preferences, loaded once from the profile. Never sent to peers.
|
||||
bool party_overlay = true, nametags = true, chat_visible = true, display_preferences_loaded{};
|
||||
bool custom_nametags = true; // ReSkate's nametags instead of the game's (Hud/custom_nametags.h)
|
||||
bool nametags = true, chat_visible = true, display_preferences_loaded{};
|
||||
float nametag_distance = 120.f; // names within this many metres, dots past it
|
||||
// Only players within this many metres have a skater (session.cpp); checked a few times a second.
|
||||
float player_distance = 120.f;
|
||||
bool prefer_direct = true; // straight to servers that offer it, not through the relays
|
||||
// Poses from a dedicated server (pose_batch.h): each player's stream as this game holds it,
|
||||
// and which of the server's messages were read in full, which it is told once a tick.
|
||||
std::unordered_map<std::uint16_t, pose_batch::Stream> pose_streams;
|
||||
pose_batch::Ack pose_ack;
|
||||
bool pose_ack_due{};
|
||||
// This game's own poses go to a dedicated server the same way: what was sent and what the
|
||||
// server acked, and the last poses sent, to build the next on.
|
||||
pose_batch::Sender pose_upload;
|
||||
std::deque<pose_batch::Stream::Held> own_poses;
|
||||
// Skaters' sound to and from a dedicated server (sound_codec.h).
|
||||
std::unordered_map<std::uint16_t, sound_codec::In> sound_streams;
|
||||
sound_codec::Sender sound_upload;
|
||||
// pose-dump: this player's own poses, as encoded for sending, written to a file for a
|
||||
// while. Real poses to measure encodings against (tools/research).
|
||||
std::ofstream pose_dump;
|
||||
std::uint64_t pose_dump_until{};
|
||||
std::size_t pose_dump_count{};
|
||||
std::uint64_t next_shown_rank{};
|
||||
bool nametag_dots = true, nametags_friends{};
|
||||
bool chat_filter = true; // bad words in chat show as **** (Engine/Core/Text/word_filter.h)
|
||||
// Chat bubbles above each skater's head (Hud/custom_nametags.h, nametag_overlay.cpp).
|
||||
bool chat_bubbles = true, chat_bubbles_own{};
|
||||
float chat_bubbles_distance = 40.f, chat_bubbles_duration = 5.f;
|
||||
int chat_bubbles_history = 3;
|
||||
bool game_menu{}; // a game menu is up or the game's UI is hidden: no chat on screen
|
||||
bool hooks_prepared{}; // the remote-player hooks were installed (once per process)
|
||||
// Host settings remembered between sessions and game restarts.
|
||||
@@ -212,6 +274,7 @@ struct Session {
|
||||
std::string lobby_name;
|
||||
MultiplayerDistances distances;
|
||||
ObjectPlacement placement = ObjectPlacement::everyone;
|
||||
unsigned object_limit = default_object_limit;
|
||||
float voice_range = default_voice_range;
|
||||
bool guest_noclip = true, guest_no_bail = true, guest_boosts = true;
|
||||
bool enforce_tuning = true;
|
||||
@@ -257,8 +320,11 @@ struct Session {
|
||||
std::uint64_t object_owners_at{};
|
||||
// Throwdown messages received since the last relay tick: sender, encoded message.
|
||||
std::vector<std::pair<std::uint64_t, std::vector<std::uint8_t>>> throwdown_inbox;
|
||||
// Parties (session_party.cpp): the local player's own roster entry, and the invites a
|
||||
// dedicated server passed on (newest last). A listen host's lobby is always one party.
|
||||
// Parties (session_party.cpp): the local player's own roster entry, and the invites the
|
||||
// host passed on (newest last). Whoever hosts owns the parties: a dedicated server, or in
|
||||
// a lobby the host's game, in `parties`. Nobody is in one until they form it.
|
||||
PartyBook parties;
|
||||
std::uint64_t parties_revision{}; // the book's revision the last roster was built from
|
||||
std::uint32_t local_party{};
|
||||
bool local_party_leader{}, local_party_open{};
|
||||
bool local_speeding{}; // the dedicated server flagged our game speed: no linked activities
|
||||
@@ -286,6 +352,33 @@ template <class F> void each_active_peer(Session &s, F &&fn) {
|
||||
}
|
||||
// A guest whose host is a dedicated server (a Steam game server, not a player).
|
||||
inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && game_server_steam_id(s.host_id); }
|
||||
// Whether Steam itself vouches for this player's identity to this PC: a host's guests and a
|
||||
// guest's host are connected directly, and so is another guest once the direct handshake is
|
||||
// done. Anyone else is known only from the host's roster, which a host can fill as it likes,
|
||||
// so what rests on who a player is (the developer and friend marks) waits for this. An
|
||||
// official server is the exception: Steam vouches for it to this PC and for each player to
|
||||
// it, and it is ours, so its roster is taken at its word. On a busy one a player is
|
||||
// connected directly to only the few nearest, and the rest would show no tag.
|
||||
inline bool steam_vouched(const Session &s, const Peer &peer) {
|
||||
return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready || official_server(s.host_id);
|
||||
}
|
||||
// Whether a player shows the tag the backend gives them, and whether the items that come with
|
||||
// it animate: not until their appearance has arrived, and not when it says they turned that
|
||||
// off (Appearance::hide_tag, Appearance::hide_items).
|
||||
inline bool shows_tag(const Peer &peer) {
|
||||
const auto &look = peer.appearance.value();
|
||||
return look && !look->hide_tag;
|
||||
}
|
||||
inline bool shows_items(const Peer &peer) {
|
||||
const auto &look = peer.appearance.value();
|
||||
return look && !look->hide_items;
|
||||
}
|
||||
// How a player has each of their marked cosmetics animate: what their appearance says.
|
||||
inline const MarkStyles &mark_styles(const Peer &peer) {
|
||||
static const MarkStyles standard{};
|
||||
const auto &look = peer.appearance.value();
|
||||
return look ? look->marks : standard;
|
||||
}
|
||||
Peer *find_peer(Session &s, std::uint64_t id);
|
||||
unsigned player_count(const Session &s);
|
||||
void reset_peer(Session &s, std::size_t slot);
|
||||
@@ -302,15 +395,20 @@ void save_bans(const Session &s);
|
||||
bool is_banned(Session &s, std::uint64_t id);
|
||||
// Re-reads friend_ids when the Steam social snapshot has changed.
|
||||
void refresh_friends(Session &s);
|
||||
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false);
|
||||
// A player's role colour and badge ("Dev", "Admin", "Host", "Friend" or none), shown in chat
|
||||
// `marks`: whether the line may show the badge the backend gives its sender (player_role).
|
||||
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false, bool marks = true);
|
||||
// The colour and badge of one of the backend's categories.
|
||||
std::pair<std::uint32_t, std::string> mark_role(IdentityList list);
|
||||
// A player's role colour and badge ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat
|
||||
// and on their nametag. `local`: the local player.
|
||||
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t id, bool local);
|
||||
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t id, bool local, bool marks = true);
|
||||
// Sends one line from this player; returns why not when it cannot.
|
||||
std::string send_chat(Session &s, std::string_view typed);
|
||||
// A "/" command for a dedicated server (votes, and any server command for its admins): sent
|
||||
// like chat but never shown as a line; the server answers in chat.
|
||||
std::string send_chat_command(Session &s, std::string_view typed);
|
||||
// "/p <message>" in a lobby: one line for the local player's party only, relayed by the host.
|
||||
std::string send_party_chat(Session &s, std::string_view typed);
|
||||
// The "/" commands this session offers (the chat overlay lists them as the player types "/").
|
||||
std::vector<MultiplayerChatCommand> chat_commands(const Session &s);
|
||||
// One encoded throwdown message from this player to everyone else (through the host).
|
||||
@@ -352,8 +450,18 @@ std::uint64_t party_leader(const Session &s); // the local party's leader, 0 wit
|
||||
void set_local_party(Session &s, const Member &local);
|
||||
void receive_party(Session &s, const Packet &p, std::uint64_t now);
|
||||
void expire_party_invites(Session &s, std::uint64_t now);
|
||||
// Sends a request to the dedicated server; returns why it can't, or empty.
|
||||
// Sends a request to whoever owns the parties (a lobby's host answers its own at once);
|
||||
// returns why it can't, or empty.
|
||||
std::string send_party_request(Session &s, PartyAction action, std::uint64_t player);
|
||||
// A lobby's host: answers `from`'s party request, as a dedicated server does.
|
||||
void host_party_request(Session &s, std::uint64_t from, PartyAction action, std::uint64_t player, std::uint64_t now);
|
||||
// A lobby's host: `from`'s "/p" line, relayed to the rest of their party only.
|
||||
void host_party_chat(Session &s, std::uint64_t from, std::string_view text, std::uint64_t now);
|
||||
// A lobby's host, every network tick: players who left are out of their parties, lapsed
|
||||
// invites are withdrawn, and a change sends a new roster.
|
||||
void tick_host_parties(Session &s, std::uint64_t now);
|
||||
// A lobby's host: each roster member's party as the book has it.
|
||||
void fill_roster_parties(Session &s, std::vector<Member> &members);
|
||||
bool accept_data(Peer &peer, const Packet &p, std::uint64_t now);
|
||||
// The same, moving a pose into the playback buffer (the packet's pose is left empty).
|
||||
bool accept_data(Peer &peer, Packet &&p, std::uint64_t now);
|
||||
@@ -361,6 +469,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now);
|
||||
// session_commands.cpp
|
||||
void apply_distances(Session &s, const MultiplayerDistances &distances);
|
||||
void apply_object_placement(Session &s, ObjectPlacement policy);
|
||||
// The session's limit, and this game's own share of it: none while hosting or as a server's admin.
|
||||
void apply_object_limit(Session &s, unsigned limit);
|
||||
// Stores what guests may use and applies it to the local player (the host and a dedicated
|
||||
// server's admins are exempt).
|
||||
void apply_guest_tools(Session &s, bool noclip, bool no_bail, bool boosts);
|
||||
|
||||
@@ -2,13 +2,61 @@
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
#include <algorithm>
|
||||
|
||||
// Parties. A listen host's lobby is always one party, led by the host; on a dedicated server
|
||||
// the server owns the parties (Server/server_party.cpp) and every roster says who is in which.
|
||||
// The native party UI, map markers and coop challenges read them from here.
|
||||
// Parties. Whoever hosts owns them, in a PartyBook: a dedicated server (Server/server_party.cpp)
|
||||
// or, in a lobby, the host's game (the second half of this file, which answers requests the way
|
||||
// the server does). Nobody is in a party until they form one, and every roster says who is in
|
||||
// which. The native party UI, map markers and coop challenges read them from here.
|
||||
namespace dingosdk::multiplayer::session_detail {
|
||||
namespace {
|
||||
// An invite the server passed on lapses there after a minute; drop ours a little later.
|
||||
// An invite the host passed on lapses there after a minute; drop ours a little later.
|
||||
constexpr std::uint64_t invite_lifetime_us = 65000000;
|
||||
std::string_view refusal(PartyBook::Result result) {
|
||||
switch (result) {
|
||||
case PartyBook::Result::ok: return {};
|
||||
case PartyBook::Result::self: return "That's you.";
|
||||
case PartyBook::Result::same_party: return "You're already in a party together.";
|
||||
case PartyBook::Result::full: return "That party is full.";
|
||||
case PartyBook::Result::not_leader: return "Only the party leader can do that.";
|
||||
case PartyBook::Result::not_member: return "You're not in a party with them.";
|
||||
case PartyBook::Result::no_invite: return "That invite has expired.";
|
||||
case PartyBook::Result::closed: return "That party is invite-only.";
|
||||
case PartyBook::Result::no_party: return "They're not in a party.";
|
||||
case PartyBook::Result::busy: return "They already have too many party invites.";
|
||||
case PartyBook::Result::renewed: return "They already have your party invite; it stays open for another minute.";
|
||||
}
|
||||
return "That can't be done.";
|
||||
}
|
||||
std::string player_name(Session &s, std::uint64_t id) {
|
||||
if (const auto *peer = find_peer(s, id); peer && !peer->member.name.empty()) return peer->member.name;
|
||||
return s.transport.name(id);
|
||||
}
|
||||
// The host itself, or a guest it has admitted.
|
||||
bool in_lobby(Session &s, std::uint64_t id) {
|
||||
if (id == s.transport.status().local_id) return true;
|
||||
const auto *peer = find_peer(s, id);
|
||||
return peer && peer->handshaken;
|
||||
}
|
||||
// A line only `to` reads: in the host's own chat, or sent to the guest as the host's notice.
|
||||
void tell(Session &s, std::uint64_t to, std::string_view text, std::uint64_t now) {
|
||||
if (to == s.transport.status().local_id) return add_chat(s, 0, "ReSkate", std::string(text));
|
||||
auto notice = packet(s, PacketKind::admin, now);
|
||||
notice.text = clean_chat_text(text);
|
||||
send_packet(s, to, notice, true, false);
|
||||
}
|
||||
// An invite for `to`, or word that one can no longer be accepted.
|
||||
void notify(Session &s, std::uint64_t to, PartyAction action, std::uint64_t player, std::uint64_t now) {
|
||||
auto notice = packet(s, PacketKind::party, now);
|
||||
notice.party_action = action;
|
||||
notice.party_player = player;
|
||||
if (to == s.transport.status().local_id) receive_party(s, notice, now);
|
||||
else send_packet(s, to, notice, true, false);
|
||||
}
|
||||
void party_notice(Session &s, std::uint32_t party, std::string_view text, std::uint64_t now, std::uint64_t except = 0) {
|
||||
const auto *details = s.parties.party(party);
|
||||
if (!details) return;
|
||||
for (const auto member : details->members)
|
||||
if (member != except && in_lobby(s, member)) tell(s, member, text, now);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::uint32_t party_of(const Session &s, std::uint64_t id) {
|
||||
@@ -68,15 +116,169 @@ void expire_party_invites(Session &s, std::uint64_t now) {
|
||||
if (s.party_invites.size() != before) ++s.party_revision;
|
||||
}
|
||||
std::string send_party_request(Session &s, PartyAction action, std::uint64_t player) {
|
||||
if (!dedicated_host(s)) return "Everyone in a lobby is in one party with the host.";
|
||||
if (s.mode != Mode::host && s.mode != Mode::join) return "Parties need a multiplayer session.";
|
||||
if (!valid_party_request(action, player) || action == PartyAction::invited || action == PartyAction::withdrawn)
|
||||
return "That party request isn't valid.";
|
||||
if (player && !find_peer(s, player)) return "That player is not in the session.";
|
||||
const auto now = now_us();
|
||||
if (s.mode == Mode::host) {
|
||||
host_party_request(s, s.transport.status().local_id, action, player, now);
|
||||
return {};
|
||||
}
|
||||
auto request = packet(s, PacketKind::party, now);
|
||||
request.party_action = action;
|
||||
request.party_player = player;
|
||||
if (!send_packet(s, s.host_id, request, true, false)) return "Could not reach the server.";
|
||||
if (!send_packet(s, s.host_id, request, true, false))
|
||||
return dedicated_host(s) ? "Could not reach the server." : "Could not reach the host.";
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A lobby's host. The same answers, word for word, as a dedicated server gives
|
||||
// (Server/server_party.cpp), so a party works the same wherever it is formed.
|
||||
void host_party_request(Session &s, std::uint64_t me, PartyAction action, std::uint64_t player, std::uint64_t now) {
|
||||
if (s.mode != Mode::host || !in_lobby(s, me)) return;
|
||||
if (player && !in_lobby(s, player)) return tell(s, me, "That player is not in the lobby.", now);
|
||||
const auto my_name = player_name(s, me);
|
||||
const auto their_name = player ? player_name(s, player) : std::string{};
|
||||
auto &parties = s.parties;
|
||||
using R = PartyBook::Result;
|
||||
auto result = R::ok;
|
||||
switch (action) {
|
||||
case PartyAction::invite:
|
||||
result = parties.invite(me, player, now);
|
||||
if (result == R::ok) {
|
||||
notify(s, player, PartyAction::invited, me, now);
|
||||
tell(s, me, "Invited " + their_name + " to your party.", now);
|
||||
}
|
||||
break;
|
||||
case PartyAction::accept:
|
||||
case PartyAction::join: {
|
||||
const auto before = parties.party_of(me);
|
||||
result = action == PartyAction::accept ? parties.accept(me, player, now) : parties.join(me, player, now);
|
||||
if (result == R::ok) {
|
||||
const auto party = parties.party_of(me);
|
||||
if (before && before != party) party_notice(s, before, my_name + " left the party.", now);
|
||||
party_notice(s, party, my_name + " joined the party.", now, me);
|
||||
tell(s, me, "You joined " + their_name + "'s party.", now);
|
||||
} else if (result == R::closed) {
|
||||
// Ask the leader instead: they can invite.
|
||||
const auto *details = parties.party(parties.party_of(player));
|
||||
if (details && in_lobby(s, details->leader)) {
|
||||
tell(s, details->leader, my_name + " would like to join your party. Invite them from their player card "
|
||||
"or with /party invite " + my_name, now);
|
||||
return tell(s, me, "That party is invite-only; its leader was asked to invite you.", now);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PartyAction::decline:
|
||||
result = parties.decline(me, player);
|
||||
if (result == R::ok) tell(s, player, my_name + " declined your party invite.", now);
|
||||
break;
|
||||
case PartyAction::leave: {
|
||||
const auto party = parties.party_of(me);
|
||||
result = parties.leave(me);
|
||||
if (result == R::ok) {
|
||||
party_notice(s, party, my_name + " left the party.", now);
|
||||
tell(s, me, "You left the party.", now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PartyAction::kick: {
|
||||
const auto party = parties.party_of(me);
|
||||
result = parties.kick(me, player);
|
||||
if (result == R::ok) {
|
||||
tell(s, player, "You were removed from the party.", now);
|
||||
party_notice(s, party, their_name + " was removed from the party.", now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PartyAction::promote:
|
||||
result = parties.promote(me, player);
|
||||
if (result == R::ok) party_notice(s, parties.party_of(me), their_name + " now leads the party.", now);
|
||||
break;
|
||||
case PartyAction::open:
|
||||
case PartyAction::close: {
|
||||
const auto *before = parties.party(parties.party_of(me));
|
||||
const bool was_open = before && before->open;
|
||||
result = parties.set_open(me, action == PartyAction::open);
|
||||
if (result == R::ok && was_open != (action == PartyAction::open))
|
||||
party_notice(s, parties.party_of(me), action == PartyAction::open ? "The party is open: anyone can join."
|
||||
: "The party is invite-only.", now);
|
||||
break;
|
||||
}
|
||||
case PartyAction::invited:
|
||||
case PartyAction::withdrawn: return; // the host's own notices
|
||||
}
|
||||
if (result != R::ok) tell(s, me, refusal(result), now);
|
||||
if (parties.revision() != s.parties_revision) s.roster_dirty = true;
|
||||
}
|
||||
void host_party_chat(Session &s, std::uint64_t from, std::string_view text, std::uint64_t now) {
|
||||
if (s.mode != Mode::host) return;
|
||||
const auto party = s.parties.party_of(from);
|
||||
if (!party) return tell(s, from, "You're not in a party.", now);
|
||||
const auto line = clean_chat_text(text);
|
||||
if (line.empty()) return tell(s, from, "/p <message>", now);
|
||||
// Relayed as the sender's own chat line, only to the rest of their party.
|
||||
const auto local = s.transport.status().local_id;
|
||||
auto message = packet(s, PacketKind::chat, now);
|
||||
message.text = clean_chat_text("[Party] " + line);
|
||||
if (from != local) {
|
||||
const auto *sender = find_peer(s, from);
|
||||
if (!sender) return;
|
||||
message.source = from;
|
||||
message.epoch = sender->member.epoch;
|
||||
}
|
||||
for (const auto member : s.parties.party(party)->members) {
|
||||
if (member == from) continue;
|
||||
if (member == local) add_chat(s, from, player_name(s, from), message.text);
|
||||
else send_packet(s, member, message, true, false);
|
||||
}
|
||||
}
|
||||
void tick_host_parties(Session &s, std::uint64_t now) {
|
||||
if (s.mode != Mode::host) return;
|
||||
auto &parties = s.parties;
|
||||
// A player who left the lobby is out of their party, and their invites go with them.
|
||||
std::vector<std::uint64_t> gone;
|
||||
const auto check = [&](std::uint64_t id) {
|
||||
if (!in_lobby(s, id) && std::find(gone.begin(), gone.end(), id) == gone.end()) gone.push_back(id);
|
||||
};
|
||||
for (const auto &[id, details] : parties.parties())
|
||||
for (const auto member : details.members) check(member);
|
||||
for (const auto &invite : parties.invites()) { check(invite.from); check(invite.to); }
|
||||
for (const auto id : gone) {
|
||||
const auto party = parties.party_of(id);
|
||||
const auto name = player_name(s, id);
|
||||
parties.remove(id);
|
||||
if (party) party_notice(s, party, name + " left the lobby.", now);
|
||||
}
|
||||
for (const auto &lapsed : parties.expire(now)) {
|
||||
if (in_lobby(s, lapsed.to)) notify(s, lapsed.to, PartyAction::withdrawn, lapsed.from, now);
|
||||
if (in_lobby(s, lapsed.from) && in_lobby(s, lapsed.to))
|
||||
tell(s, lapsed.from, "Your party invite to " + player_name(s, lapsed.to) + " expired.", now);
|
||||
}
|
||||
for (const auto &withdrawn : parties.take_withdrawn())
|
||||
if (in_lobby(s, withdrawn.to)) notify(s, withdrawn.to, PartyAction::withdrawn, withdrawn.from, now);
|
||||
if (parties.revision() != s.parties_revision) s.roster_dirty = true;
|
||||
}
|
||||
void fill_roster_parties(Session &s, std::vector<Member> &members) {
|
||||
for (auto &m : members) {
|
||||
const auto party = s.parties.party_of(m.id);
|
||||
const auto *details = s.parties.party(party);
|
||||
m.party = party;
|
||||
m.party_leader = details && details->leader == m.id;
|
||||
m.party_open = m.party_leader && details->open;
|
||||
}
|
||||
// The book holds only players in the lobby (tick_host_parties), so this only guards the
|
||||
// roster's rules: every listed party has two members and one leader.
|
||||
for (auto &m : members)
|
||||
if (m.party && std::count_if(members.begin(), members.end(), [&](const Member &o) { return o.party == m.party; }) < 2)
|
||||
m.party = 0, m.party_leader = m.party_open = false;
|
||||
for (auto &m : members)
|
||||
if (m.party && std::none_of(members.begin(), members.end(),
|
||||
[&](const Member &o) { return o.party == m.party && o.party_leader; }))
|
||||
m.party_leader = true; // the first listed member of a party missing its leader
|
||||
s.parties_revision = s.parties.revision();
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::session_detail
|
||||
@@ -60,11 +60,143 @@ void receive_cosmetics(Session &s, const NativeFrame &local, std::uint64_t now)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chat proofs. A guest takes chat from the host alone, which passes every other guest's lines
|
||||
// on, and a host can pass on what it likes under any of its players' names. That is no worse
|
||||
// than a name on a line, until the line shows a badge of the backend's (Dev, Creator, Homie):
|
||||
// then it is a line a developer never wrote, with their badge on it. So such a guest also
|
||||
// sends each line straight to the players Steam connects them to (send_chat), and here a line
|
||||
// the host passed on as theirs shows their badge only when that copy says the same. One
|
||||
// without it waits a moment for it, then shows as any other player's would: from an older
|
||||
// build, a party line (which the host writes), or not theirs at all.
|
||||
constexpr std::uint64_t chat_proof_wait = 500000, chat_proof_life = 10000000;
|
||||
constexpr std::size_t chat_proofs_kept = 8;
|
||||
// Whether a line the host passed on as this player's would show a badge that rests on who
|
||||
// they are: another guest's, known to Steam, on one of the backend's lists and showing it.
|
||||
bool owes_proof(const Session &s, const Peer &sender) {
|
||||
// Their own copy only comes over a direct connection: without one there is none to wait for.
|
||||
return s.mode == Mode::join && sender.member.id != s.host_id && sender.direct_ready && shows_tag(sender) &&
|
||||
identity_mark(sender.member.id);
|
||||
}
|
||||
std::string chat_name(Session &s, const Peer &sender) {
|
||||
return sender.member.name.empty() ? s.transport.name(sender.member.id) : sender.member.name;
|
||||
}
|
||||
// Shows the lines that have waited long enough (or all of them), without the badge.
|
||||
void release_chat(Session &s, Peer &sender, std::uint64_t now, bool all) {
|
||||
auto &waiting = sender.chat_waiting;
|
||||
auto line = waiting.begin();
|
||||
for (; line != waiting.end() && (all || now < line->at || now - line->at >= chat_proof_wait); ++line)
|
||||
add_chat(s, sender.member.id, chat_name(s, sender), std::move(line->text), false, false);
|
||||
waiting.erase(waiting.begin(), line);
|
||||
}
|
||||
// The host's copy of a line: shown at once when the sender's own copy is here, else it waits.
|
||||
void relayed_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) {
|
||||
auto &proofs = sender.chat_proofs;
|
||||
const auto proof = std::find_if(proofs.begin(), proofs.end(), [&](const auto ©) {
|
||||
return copy.sequence == p.sequence && copy.text == p.text && now >= copy.at && now - copy.at <= chat_proof_life;
|
||||
});
|
||||
const bool proven = proof != proofs.end();
|
||||
if (proven) proofs.erase(proof);
|
||||
// The sender's copies come in the order of their lines: with this one's here, or none
|
||||
// owed for it, nothing is on its way for the lines before it.
|
||||
if (proven || p.text.starts_with("[Party] ")) {
|
||||
release_chat(s, sender, now, true);
|
||||
add_chat(s, sender.member.id, chat_name(s, sender), p.text, false, proven);
|
||||
return;
|
||||
}
|
||||
if (sender.chat_waiting.size() >= chat_proofs_kept) release_chat(s, sender, now, true);
|
||||
sender.chat_waiting.push_back({p.sequence, p.text, now});
|
||||
}
|
||||
// The sender's own copy of a line, over their own connection. Never shown by itself.
|
||||
void proven_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) {
|
||||
if (!owes_proof(s, sender)) return;
|
||||
auto &waiting = sender.chat_waiting;
|
||||
const auto held = std::find_if(waiting.begin(), waiting.end(), [&](const auto &line) {
|
||||
return line.sequence == p.sequence && line.text == p.text;
|
||||
});
|
||||
if (held != waiting.end()) {
|
||||
for (auto line = waiting.begin(); line != held; ++line)
|
||||
add_chat(s, sender.member.id, chat_name(s, sender), std::move(line->text), false, false);
|
||||
add_chat(s, sender.member.id, chat_name(s, sender), std::move(held->text));
|
||||
waiting.erase(waiting.begin(), held + 1);
|
||||
return;
|
||||
}
|
||||
auto &proofs = sender.chat_proofs;
|
||||
std::erase_if(proofs, [&](const auto ©) { return now < copy.at || now - copy.at > chat_proof_life; });
|
||||
if (proofs.size() >= chat_proofs_kept) proofs.erase(proofs.begin());
|
||||
proofs.push_back({p.sequence, p.text, now});
|
||||
}
|
||||
// A hitch leaves a backlog of pose updates, all of which would be decoded in one frame.
|
||||
// Every pose delta references a reliable baseline, never another delta, so older deltas
|
||||
// of one stream in a batch can be dropped undecoded. Keep the newest ones playback may
|
||||
// still interpolate between (its delay plus a frame each side) among those whose baseline
|
||||
// is already here; baselines and every other kind are always read.
|
||||
// The poses in one message from a dedicated server (pose_batch.h), each as the packet it would
|
||||
// have been had it come alone. A message read in full is noted for the ack; one with a pose
|
||||
// this game could not rebuild (it never got what that pose builds on) is not, so the server
|
||||
// goes on building on what this game does hold.
|
||||
std::vector<Packet> unpack_poses(Session &s, std::span<const std::uint8_t> bytes) {
|
||||
std::vector<Packet> out;
|
||||
const auto batch = pose_batch::read(bytes);
|
||||
if (!batch || batch->world != static_cast<std::uint32_t>(s.world) || batch->map != static_cast<std::uint32_t>(s.map)) return out;
|
||||
// Streams of players who left are dropped once there are many more than there are players.
|
||||
if (s.pose_streams.size() > 2 * max_players)
|
||||
std::erase_if(s.pose_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); });
|
||||
bool complete = true;
|
||||
out.reserve(batch->entries.size());
|
||||
for (const auto &entry : batch->entries) {
|
||||
auto rebuilt = pose_batch::rebuild(s.pose_streams, entry);
|
||||
if (!rebuilt) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
if (rebuilt->repeat) continue;
|
||||
Packet p;
|
||||
p.kind = PacketKind::pose;
|
||||
p.session = s.secret;
|
||||
p.map = s.map;
|
||||
p.world = s.world;
|
||||
p.source = rebuilt->source;
|
||||
p.epoch = rebuilt->epoch;
|
||||
p.sequence = rebuilt->sequence;
|
||||
p.time_us = rebuilt->time_us;
|
||||
p.player_collision = rebuilt->collision;
|
||||
p.pose_interval_us = rebuilt->rate == pose_batch::Rate::full ? multiplayer_pose_interval(s.tps)
|
||||
: rebuilt->rate == pose_batch::Rate::half ? 100000U
|
||||
: 200000U;
|
||||
p.pose = pose_codec::restore(rebuilt->pose);
|
||||
out.push_back(std::move(p));
|
||||
}
|
||||
if (complete) {
|
||||
s.pose_ack.note(batch->number);
|
||||
s.pose_ack_due = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// The skaters' sound in one message from a dedicated server (sound_codec.h), each player's as
|
||||
// the packet it would have been had it come alone.
|
||||
std::vector<Packet> unpack_sound(Session &s, std::span<const std::uint8_t> bytes) {
|
||||
std::vector<Packet> out;
|
||||
if (s.sound_streams.size() > 2 * max_players)
|
||||
std::erase_if(s.sound_streams, [&](const auto &stream) { return !find_peer(s, stream.second.source); });
|
||||
auto heard = sound_codec::read(s.sound_streams, bytes, s.world, s.map);
|
||||
if (!heard) return out;
|
||||
out.reserve(heard->size());
|
||||
for (auto &one : *heard) {
|
||||
if (!valid_audio_batch(one.samples)) continue;
|
||||
Packet p;
|
||||
p.kind = PacketKind::audio;
|
||||
p.session = s.secret;
|
||||
p.map = s.map;
|
||||
p.world = s.world;
|
||||
p.source = one.source;
|
||||
p.epoch = one.epoch;
|
||||
p.sequence = one.sequence;
|
||||
p.time_us = one.time_us;
|
||||
p.audio = std::move(one.samples);
|
||||
out.push_back(std::move(p));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::vector<bool> stale_pose_deltas(Session &s, const std::vector<TransportMessage> &messages) {
|
||||
std::vector<bool> stale(messages.size());
|
||||
struct Stream {
|
||||
@@ -74,6 +206,8 @@ std::vector<bool> stale_pose_deltas(Session &s, const std::vector<TransportMessa
|
||||
std::vector<Stream> streams;
|
||||
for (auto i = messages.size(); i-- > 0;) {
|
||||
const auto &bytes = messages[i].bytes;
|
||||
// (A dedicated server sends none of these: what it sends that starts the same is sound.)
|
||||
if (dedicated_host(s) && messages[i].peer == s.host_id) continue;
|
||||
// Sparse pose patches (delta_codec.cpp): "RMS1"/"RMS2", source at 4, kind at 20.
|
||||
if (bytes.size() <= 34 || bytes[0] != 'R' || bytes[1] != 'M' || bytes[2] != 'S' ||
|
||||
(bytes[3] != '1' && bytes[3] != '2'))
|
||||
@@ -138,6 +272,7 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) {
|
||||
for (auto &peer : active_peers(s)) peer.pose_delivery = {};
|
||||
}
|
||||
apply_object_placement(s, p.object_placement);
|
||||
apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin
|
||||
apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts);
|
||||
s.enforce_tuning = p.enforce_tuning;
|
||||
s.server_votes = dedicated_host(s) ? p.server_votes : 0;
|
||||
@@ -157,6 +292,7 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) {
|
||||
if (old[i].id != next[i].id || old[i].epoch != next[i].epoch) {
|
||||
if (old[i].id && old[i].id != s.host_id)
|
||||
s.transport.disconnect(old[i].id, "Player left or rejoined the host roster.");
|
||||
release_chat(s, s.peers[i], now, true); // what they said just before leaving
|
||||
reset_peer(s, i);
|
||||
s.peers[i].last_packet = now;
|
||||
}
|
||||
@@ -171,6 +307,8 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) {
|
||||
}
|
||||
s.capacity = p.capacity;
|
||||
s.roster_voice_range = p.voice_range;
|
||||
s.server_chat_badge = p.chat_badge;
|
||||
s.server_chat_text = p.chat_text;
|
||||
++s.party_revision; // anyone's party may have changed
|
||||
// A dedicated server knows players only by the name each sent in their hello.
|
||||
for (auto &peer : active_peers(s))
|
||||
@@ -183,13 +321,13 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) {
|
||||
bool accept_data(Peer &peer, const Packet &p, std::uint64_t now) {
|
||||
bool accepted{};
|
||||
if (p.kind == PacketKind::cosmetics) {
|
||||
accepted = peer.appearance.push(p);
|
||||
accepted = peer.outfit_budget.accept(now) && peer.appearance.push(p);
|
||||
if (accepted) {
|
||||
peer.cosmetic_packet = encode_wire(p);
|
||||
++peer.cosmetic_revision;
|
||||
}
|
||||
} else if (p.kind == PacketKind::audio)
|
||||
accepted = peer.audio.push(p, now);
|
||||
accepted = peer.sound_budget.accept(now, p.audio.size()) && peer.audio.push(p, now);
|
||||
else if (p.kind == PacketKind::pose)
|
||||
accepted = peer.poses.push_validated(p, now);
|
||||
if (accepted) {
|
||||
@@ -222,8 +360,11 @@ bool accept_data(Peer &peer, Packet &&p, std::uint64_t now) {
|
||||
}
|
||||
void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
s.network_now = now;
|
||||
s.join_backoff.prune(now);
|
||||
trim_slots(s);
|
||||
if (world_playing(s, local)) s.local_root = local.pose.root;
|
||||
for (auto &peer : active_peers(s))
|
||||
if (!peer.chat_waiting.empty()) release_chat(s, peer, now, false);
|
||||
s.transport.poll();
|
||||
if (s.mode == Mode::join && s.transport.status().telemetry) {
|
||||
const auto &t = s.transport.status();
|
||||
@@ -264,6 +405,17 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
stop(s, s.transport.status().detail);
|
||||
return;
|
||||
}
|
||||
// The backend's bans (reskate_banned) arrive while a session runs: a banned player stops
|
||||
// hosting theirs, and nobody stays with a banned host. A banned guest is for the host or
|
||||
// the server to turn away (below, and Host::tick), which a server may choose not to.
|
||||
if (s.mode == Mode::host && reskate_banned(s.transport.status().local_id)) {
|
||||
stop(s, std::string(banned_notice));
|
||||
return;
|
||||
}
|
||||
if (s.mode == Mode::join && reskate_banned(s.host_id)) {
|
||||
stop(s, "This host is banned from ReSkate multiplayer.");
|
||||
return;
|
||||
}
|
||||
for (const auto &link : links) {
|
||||
if (s.mode == Mode::host && s.banned.contains(link.id)) {
|
||||
s.transport.disconnect(link.id, "You were kicked from this session.");
|
||||
@@ -273,7 +425,15 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
s.transport.disconnect(link.id, "You are banned from this host's lobbies.");
|
||||
continue;
|
||||
}
|
||||
if (s.mode == Mode::host && reskate_banned(link.id)) {
|
||||
s.transport.disconnect(link.id, banned_notice.data());
|
||||
continue;
|
||||
}
|
||||
auto *p = find_peer(s, link.id);
|
||||
if (!p && s.mode == Mode::host && s.join_backoff.waiting(link.id, now)) {
|
||||
s.transport.disconnect(link.id, "Too many failed attempts to join. Wait a little and try again.");
|
||||
continue;
|
||||
}
|
||||
if (!p && s.mode == Mode::host)
|
||||
p = &reserve(s, link.id, now);
|
||||
if (!p) {
|
||||
@@ -323,13 +483,24 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
}
|
||||
const auto messages = s.transport.receive();
|
||||
const auto stale = stale_pose_deltas(s, messages);
|
||||
for (std::size_t index = 0; index < messages.size(); ++index) {
|
||||
// A message of several poses from a dedicated server is taken apart, and the loop goes
|
||||
// round once for each pose in it as if it had come alone.
|
||||
std::vector<Packet> batch;
|
||||
std::size_t batch_at{};
|
||||
for (std::size_t index = 0; index < messages.size(); batch_at < batch.size() ? index : ++index) {
|
||||
const auto &message = messages[index];
|
||||
auto *link = find_peer(s, message.peer);
|
||||
if (!link)
|
||||
if (!link) {
|
||||
batch.clear();
|
||||
batch_at = 0;
|
||||
continue;
|
||||
}
|
||||
const bool direct_link = s.mode == Mode::join && message.peer != s.host_id;
|
||||
if (!link->budget.accept(now, message.bytes.size(),
|
||||
const bool resumed = batch_at < batch.size();
|
||||
if (!resumed) {
|
||||
batch.clear();
|
||||
batch_at = 0;
|
||||
if (!link->budget.accept(message.arrived ? message.arrived : now, message.bytes.size(),
|
||||
s.mode == Mode::join && !direct_link ? max_remote_players : 1U)) {
|
||||
disconnect(s, message.peer, "Peer exceeded the multiplayer packet limit.");
|
||||
if (s.mode == Mode::off)
|
||||
@@ -338,8 +509,29 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
}
|
||||
if (stale[index])
|
||||
continue;
|
||||
// A dedicated server's own messages (pose_batch.h, sound_codec.h). Only what comes
|
||||
// from the server is read as one: sound_codec's first four bytes are also those of
|
||||
// the pose updates games send each other directly (delta_codec's "RMS1"), which
|
||||
// must go on to be decoded as what they are.
|
||||
if (s.mode == Mode::join && !direct_link && dedicated_host(s)) {
|
||||
// The server saying which of this game's own pose messages it read.
|
||||
if (const auto ack = pose_batch::Ack::read(message.bytes)) {
|
||||
s.pose_upload.ack(*ack);
|
||||
continue;
|
||||
}
|
||||
if (sound_codec::is_sound(message.bytes)) {
|
||||
batch = unpack_sound(s, message.bytes);
|
||||
if (batch.empty()) continue;
|
||||
} else if (pose_batch::is_batch(message.bytes)) {
|
||||
batch = unpack_poses(s, message.bytes);
|
||||
if (batch.empty()) continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool missing_reference{};
|
||||
auto decoded = link->receiver.receive(message.bytes, missing_reference, s.world);
|
||||
std::optional<Packet> decoded;
|
||||
if (batch_at < batch.size()) decoded = std::move(batch[batch_at++]);
|
||||
else decoded = link->receiver.receive(message.bytes, missing_reference, s.world);
|
||||
if (missing_reference)
|
||||
continue;
|
||||
if (!decoded || decoded->session != s.secret ||
|
||||
@@ -371,6 +563,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
s.travel_started = s.join_started = now;
|
||||
s.join_destination.clear();
|
||||
s.map_name.clear();
|
||||
s.map_label.clear();
|
||||
s.map = 0;
|
||||
s.map_load_submitted = false;
|
||||
s.last_map_load_check = 0;
|
||||
@@ -382,6 +575,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
s.world_state_sequence = p.sequence;
|
||||
if (!p.destination.empty()) {
|
||||
s.join_destination = s.map_name = p.destination;
|
||||
s.map_label = p.map_label;
|
||||
s.map = p.map;
|
||||
}
|
||||
s.host_world_ready = p.world_ready;
|
||||
@@ -480,6 +674,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
link->password_challenge = p.challenge;
|
||||
link->map_authorized |= p.map_authorized;
|
||||
s.join_destination = p.destination;
|
||||
s.map_label = p.map_label;
|
||||
s.world = p.world;
|
||||
s.join_map_authorized |= p.map_authorized;
|
||||
if (new_challenge)
|
||||
@@ -574,11 +769,13 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
link->world_ready = true;
|
||||
link->travel_since = 0;
|
||||
link->last_packet = now;
|
||||
if (joined) s.join_backoff.joined(message.peer);
|
||||
if (s.mode == Mode::host) {
|
||||
send_required(s, message.peer, encode_wire(packet(s, PacketKind::welcome, now)));
|
||||
if (joined) {
|
||||
send_roster(s, now);
|
||||
if (!s.tuning_packet.empty()) send_required(s, message.peer, s.tuning_packet);
|
||||
if (!s.extras_packet.empty()) send_required(s, message.peer, s.extras_packet);
|
||||
send_required(s, message.peer, s.cosmetic_packet);
|
||||
for (const auto &other : active_peers(s))
|
||||
if (other.handshaken && other.member.id != message.peer)
|
||||
@@ -651,9 +848,18 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
s.host_tuning = std::move(p.tuning);
|
||||
continue;
|
||||
}
|
||||
// And the physics its tuning does not carry (Engine/Game/Multiplayer/session_physics.h).
|
||||
if (p.kind == PacketKind::physics_extras) {
|
||||
if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id && !dedicated_host(s))
|
||||
s.host_extras = p.extras;
|
||||
continue;
|
||||
}
|
||||
if (p.kind == PacketKind::maps) {
|
||||
if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) {
|
||||
s.server_maps = p.maps;
|
||||
s.server_map_pool.clear();
|
||||
for (const auto entry : p.map_pool) s.server_map_pool.push_back(p.maps[entry]);
|
||||
s.server_map_rotation = p.map_rotation;
|
||||
publish(s);
|
||||
publish_chat(s);
|
||||
}
|
||||
@@ -667,31 +873,50 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A dedicated server telling us about a party invite.
|
||||
// Whoever hosts telling us about a party invite; or, hosting a lobby, a guest asking
|
||||
// for something to be done with their party.
|
||||
if (p.kind == PacketKind::party) {
|
||||
if (dedicated_host(s) && message.peer == s.host_id && p.source == s.host_id) receive_party(s, p, now);
|
||||
if (s.mode == Mode::join) {
|
||||
if (message.peer == s.host_id && p.source == s.host_id) receive_party(s, p, now);
|
||||
} else if (s.mode == Mode::host) {
|
||||
auto *sender = find_peer(s, p.source);
|
||||
if (!direct_link && sender && sender->handshaken && message.peer == p.source &&
|
||||
routed_source(p, sender->member, message.peer, true, s.host_id) && sender->party_budget.accept(now, 20))
|
||||
host_party_request(s, p.source, p.party_action, p.party_player, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A dedicated server's answer to one of our admin requests.
|
||||
// A dedicated server's answer to one of our admin requests, or a lobby host's to a
|
||||
// party request: a line for this player only.
|
||||
if (p.kind == PacketKind::admin) {
|
||||
if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id && dedicated_host(s))
|
||||
add_chat(s, 0, "Server", p.text);
|
||||
if (s.mode == Mode::join && message.peer == s.host_id && p.source == s.host_id)
|
||||
add_chat(s, 0, dedicated_host(s) ? "Server" : "ReSkate", p.text);
|
||||
continue;
|
||||
}
|
||||
// Chat is accepted while either side is still loading a map: it needs
|
||||
// only an authenticated sender, never the world.
|
||||
if (p.kind == PacketKind::chat) {
|
||||
auto *sender = find_peer(s, p.source);
|
||||
if (direct_link || !sender || !sender->handshaken ||
|
||||
!routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id))
|
||||
if (!sender || !sender->handshaken) continue;
|
||||
// Another guest's own copy of a line they said: proof of the host's, never a line.
|
||||
if (direct_link) {
|
||||
if (routed_source(p, sender->member, message.peer, false, s.host_id, true)) proven_chat(s, *sender, p, now);
|
||||
continue;
|
||||
}
|
||||
if (!routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id))
|
||||
continue;
|
||||
// A dedicated server speaks (welcome message, its console) as "Server".
|
||||
const bool server = dedicated_host(s) && sender->member.id == s.host_id;
|
||||
// Everyone holds everyone to the same pace, so a modified client cannot flood.
|
||||
if (!server && sender->chat_rate.accept(now, p.text, 1) != ChatRate::Verdict::accepted) continue;
|
||||
sender->last_packet = now;
|
||||
add_chat(s, sender->member.id, server ? std::string("Server")
|
||||
: sender->member.name.empty() ? s.transport.name(sender->member.id) : sender->member.name, p.text);
|
||||
// "/p": party chat, which a lobby's host relays to the sender's party and nobody else.
|
||||
if (s.mode == Mode::host && p.text.starts_with("/p ")) {
|
||||
host_party_chat(s, sender->member.id, std::string_view(p.text).substr(3), now);
|
||||
continue;
|
||||
}
|
||||
if (owes_proof(s, *sender)) relayed_chat(s, *sender, p, now);
|
||||
else add_chat(s, sender->member.id, server ? std::string("Server") : chat_name(s, *sender), p.text);
|
||||
if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source);
|
||||
continue;
|
||||
}
|
||||
@@ -758,6 +983,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
[](const auto &sample) { return sample.event; })),
|
||||
p.kind != PacketKind::cosmetics, now, p.source);
|
||||
}
|
||||
// Tell the server which of its pose messages arrived, once for everything read this tick.
|
||||
if (s.pose_ack_due && s.mode == Mode::join) {
|
||||
const auto ack = s.pose_ack.bytes();
|
||||
s.transport.send(s.host_id, ack, false, true, TrafficLane::gameplay);
|
||||
s.pose_ack_due = false;
|
||||
}
|
||||
receive_cosmetics(s, local, now);
|
||||
for (std::size_t i = 0; i < s.used_slots; ++i) {
|
||||
const auto &p = s.peers[i];
|
||||
@@ -798,6 +1029,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
}
|
||||
}
|
||||
update_scoring(s, now); // before the roster, so a host's own flag goes out with it
|
||||
tick_host_parties(s, now); // and the lobby's parties, which the roster carries
|
||||
if (s.mode == Mode::host && world_playing(s, local) && (s.roster_dirty || now - s.last_roster > 2000000))
|
||||
send_roster(s, now);
|
||||
const auto peers = active_peers(s);
|
||||
|
||||
@@ -26,6 +26,8 @@ void publish_guest_objects(Session &s) {
|
||||
if (peer.handshaken && peer.objects.revision() != peer.shared_from) {
|
||||
auto layout = peer.objects.layout();
|
||||
std::erase_if(layout, [&](const auto &object) { return peer.cleared.contains(object.id); });
|
||||
// The host's limit on each guest's objects, which a patched client cannot place past.
|
||||
layout = limited_layout(std::move(layout), peer.shared.objects(), s.object_limit);
|
||||
peer.shared.replace(layout);
|
||||
peer.shared_from = peer.objects.revision();
|
||||
}
|
||||
@@ -120,6 +122,13 @@ void disconnect(Session &s, std::uint64_t id, const std::string &reason) {
|
||||
for (std::size_t i = 0; i < s.used_slots; ++i)
|
||||
if (s.peers[i].member.id == id) {
|
||||
s.roster_dirty |= s.peers[i].handshaken;
|
||||
// Never admitted: it held a player slot meanwhile. An ID that keeps failing waits
|
||||
// longer each time before its connection is taken again.
|
||||
if (s.mode == Mode::host && !s.peers[i].handshaken) {
|
||||
const auto failures = s.join_backoff.failed(id, s.network_now);
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Multiplayer: {} did not finish joining ({}), attempt {}.", id, reason, failures);
|
||||
}
|
||||
reset_peer(s, i);
|
||||
break;
|
||||
}
|
||||
@@ -131,6 +140,10 @@ bool send_packet(Session &s, std::uint64_t id, const Packet &p, bool reliable, b
|
||||
if (!peer)
|
||||
return false;
|
||||
auto update = raw.empty() ? peer->sender.prepare(p) : peer->sender.prepare(p, raw, wire);
|
||||
// A packet that cannot be built for anyone is its source's fault, never this recipient's:
|
||||
// nothing is sent, and the recipient is not treated as unreachable.
|
||||
if (update.bytes.empty())
|
||||
return true;
|
||||
// A stream and its reliable delta references must always use the same lane.
|
||||
if (!s.transport.send(id, update.bytes, reliable || update.establishes_baseline(), fresh, traffic_lane(p.kind)))
|
||||
return false;
|
||||
@@ -183,6 +196,14 @@ std::string send_chat(Session &s, std::string_view typed) {
|
||||
message.text = text;
|
||||
broadcast(s, message, true, false, now);
|
||||
const auto local = s.transport.status().local_id;
|
||||
// The host passes a guest's line on, and could pass on anything under anyone's name. So a
|
||||
// guest whose line shows a badge of the backend's also sends it straight to every player
|
||||
// Steam connects them to: the copy is not shown, it is what lets that player know the
|
||||
// host's one is ours (chat proofs, session_receive.cpp). Older builds ignore it.
|
||||
if (s.mode == Mode::join && own_tag_shown() && identity_mark(local))
|
||||
for (const auto &peer : active_peers(s))
|
||||
if (peer.handshaken && peer.direct_ready && peer.member.id != s.host_id)
|
||||
send_packet(s, peer.member.id, message, true, false);
|
||||
add_chat(s, local, s.transport.name(local), std::move(text), true);
|
||||
return {};
|
||||
}
|
||||
@@ -207,6 +228,29 @@ std::string send_chat_command(Session &s, std::string_view typed) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
std::string send_party_chat(Session &s, std::string_view typed) {
|
||||
if (s.mode != Mode::host && s.mode != Mode::join) return "Chat needs a multiplayer session.";
|
||||
if (!s.local_party) return "You're not in a party.";
|
||||
const auto text = clean_chat_text(typed);
|
||||
if (text.empty()) return "Type a message first.";
|
||||
const auto now = now_us();
|
||||
switch (s.local_chat_rate.accept(now, text)) {
|
||||
case ChatRate::Verdict::repeated: return "You just said that.";
|
||||
case ChatRate::Verdict::too_fast: return "Slow down: one message every couple of seconds.";
|
||||
case ChatRate::Verdict::accepted: break;
|
||||
}
|
||||
const auto local = s.transport.status().local_id;
|
||||
if (s.mode == Mode::host) {
|
||||
host_party_chat(s, local, text, now);
|
||||
} else {
|
||||
// The host relays it to the rest of the party, not back to us.
|
||||
auto message = packet(s, PacketKind::chat, now);
|
||||
message.text = clean_chat_text("/p " + text);
|
||||
if (!send_packet(s, s.host_id, message, true, false)) return "Could not reach the host.";
|
||||
}
|
||||
add_chat(s, local, s.transport.name(local), "[Party] " + text, true);
|
||||
return {};
|
||||
}
|
||||
void send_throwdown(Session &s, std::vector<std::uint8_t> message) {
|
||||
if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return;
|
||||
const auto now = now_us();
|
||||
@@ -214,6 +258,25 @@ void send_throwdown(Session &s, std::vector<std::uint8_t> message) {
|
||||
p.throwdown = std::move(message);
|
||||
broadcast(s, p, true, false, now);
|
||||
}
|
||||
// This game's pose to a dedicated server: its differences from a pose of its own the server has
|
||||
// said it holds (pose_batch.h), as the server sends everyone else's.
|
||||
void upload_pose(Session &s, const Packet &packet, std::uint64_t now) {
|
||||
auto &kept = s.own_poses;
|
||||
if (!kept.empty() && kept.back().sequence >= packet.sequence) return;
|
||||
kept.push_back({packet.sequence, packet.time_us, pose_codec::quantize(packet.pose)});
|
||||
while (kept.size() > 64) kept.pop_front();
|
||||
const auto find = [&](std::uint32_t sequence) -> std::optional<pose_batch::KeptView> {
|
||||
for (auto it = kept.rbegin(); it != kept.rend(); ++it)
|
||||
if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose};
|
||||
return {};
|
||||
};
|
||||
const auto emit = [&](std::span<const std::uint8_t> message, bool reliable) {
|
||||
return s.transport.send(s.host_id, message, reliable, !reliable, TrafficLane::gameplay);
|
||||
};
|
||||
s.pose_upload.begin(s.world, s.map);
|
||||
s.pose_upload.add(packet.source, packet.epoch, *find(packet.sequence), find, false, packet.player_collision, pose_batch::Rate::full, now, emit);
|
||||
s.pose_upload.flush(emit);
|
||||
}
|
||||
void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std::uint64_t now,
|
||||
std::uint64_t except) {
|
||||
const auto *source = find_peer(s, packet.source);
|
||||
@@ -272,6 +335,19 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std:
|
||||
(packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) &&
|
||||
!needs_relay(p.direct_routes, p.route_reported, source->member, now))
|
||||
continue;
|
||||
if (packet.kind == PacketKind::pose && dedicated_host(s) && p.member.id == s.host_id) {
|
||||
upload_pose(s, packet, now);
|
||||
continue;
|
||||
}
|
||||
// This game's skater's sound to a dedicated server (sound_codec.h): what changed since
|
||||
// the last samples it was sent.
|
||||
if (packet.kind == PacketKind::audio && dedicated_host(s) && p.member.id == s.host_id) {
|
||||
s.sound_upload.begin(s.world, s.map);
|
||||
s.sound_upload.add(packet.source, packet.epoch, packet.sequence, packet.time_us, packet.audio, now);
|
||||
if (s.sound_upload.pending())
|
||||
s.sound_upload.sent(s.transport.send(s.host_id, s.sound_upload.message(), true, fresh, traffic_lane(packet.kind)));
|
||||
continue;
|
||||
}
|
||||
PoseDelivery *delivery{};
|
||||
std::uint32_t interval = multiplayer_pose_interval(s.tps);
|
||||
if (packet.kind == PacketKind::pose) {
|
||||
@@ -311,8 +387,11 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std:
|
||||
data.wire = encode_wire_bytes(data.raw);
|
||||
data.ready = true;
|
||||
}
|
||||
outgoing.push_back({&p, p.sender.prepare(packet, data.raw, data.wire, data.deltas), delivery,
|
||||
interval});
|
||||
auto update = p.sender.prepare(packet, data.raw, data.wire, data.deltas);
|
||||
// Unbuildable for anyone (see send_packet): skipped, and no recipient is dropped for it.
|
||||
if (update.bytes.empty())
|
||||
continue;
|
||||
outgoing.push_back({&p, std::move(update), delivery, interval});
|
||||
}
|
||||
if (outgoing.empty())
|
||||
return;
|
||||
@@ -384,6 +463,7 @@ void send_roster(Session &s, std::uint64_t now) {
|
||||
p.voice_range = s.voice_range;
|
||||
p.distances = s.distances;
|
||||
p.object_placement = s.object_placement;
|
||||
p.object_limit = s.object_limit;
|
||||
p.guest_noclip = s.guest_noclip;
|
||||
p.guest_no_bail = s.guest_no_bail;
|
||||
p.guest_boosts = s.guest_boosts;
|
||||
@@ -396,25 +476,28 @@ void send_roster(Session &s, std::uint64_t now) {
|
||||
p.parks = s.parks;
|
||||
p.capacity = s.capacity;
|
||||
// Names come from the transport's short-lived name cache, not a Steam call per player.
|
||||
p.members.push_back({p.source, s.epoch, s.transport.name(p.source)});
|
||||
// Steam names are cleaned to what a roster may carry: one that is not would be refused
|
||||
// by every guest, and by this host's own encoder.
|
||||
p.members.push_back({p.source, s.epoch, clean_roster_name(s.transport.name(p.source))});
|
||||
p.members.back().scoring = s.local_scoring;
|
||||
for (auto &peer : active_peers(s))
|
||||
if (peer.handshaken) {
|
||||
peer.member.name = s.transport.name(peer.member.id);
|
||||
peer.member.name = clean_roster_name(s.transport.name(peer.member.id));
|
||||
p.members.push_back(peer.member);
|
||||
}
|
||||
// A lobby is one party: everyone in it, led by the host (a party needs two players).
|
||||
for (auto &m : p.members) {
|
||||
m.party = p.members.size() > 1 ? lobby_party : 0;
|
||||
m.party_leader = m.party && m.id == p.source;
|
||||
m.party_open = false;
|
||||
}
|
||||
// Parties are the ones the lobby's players formed (session_party.cpp), as on a dedicated
|
||||
// server. The host's own copy of each player follows the roster it sends.
|
||||
fill_roster_parties(s, p.members);
|
||||
for (auto &peer : active_peers(s))
|
||||
if (peer.handshaken) {
|
||||
peer.member.party = p.members.front().party;
|
||||
peer.member.party_leader = false;
|
||||
}
|
||||
if (p.members.front().party != s.local_party) ++s.party_revision;
|
||||
if (peer.handshaken)
|
||||
for (const auto &m : p.members)
|
||||
if (m.id == peer.member.id) {
|
||||
if (peer.member.party != m.party || peer.member.party_leader != m.party_leader ||
|
||||
peer.member.party_open != m.party_open) ++s.party_revision;
|
||||
peer.member.party = m.party;
|
||||
peer.member.party_leader = m.party_leader;
|
||||
peer.member.party_open = m.party_open;
|
||||
}
|
||||
set_local_party(s, p.members.front());
|
||||
broadcast(s, p, true, false, now);
|
||||
s.roster_dirty = false;
|
||||
@@ -422,13 +505,38 @@ void send_roster(Session &s, std::uint64_t now) {
|
||||
publish_chat(s); // the host's "/" argument lists (players) follow its own roster
|
||||
}
|
||||
void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t now) {
|
||||
// A guest whose host sets everyone's physics: the player's own edits stand down from the
|
||||
// moment of joining (the roster says otherwise, if it does) and through travel, and what
|
||||
// the host shares beyond its tuning is theirs. A dedicated server shares the game's own.
|
||||
const bool enforced = s.mode == Mode::join && s.enforce_tuning;
|
||||
set_session_tuning_enforced(enforced);
|
||||
if (enforced && s.host_extras && !dedicated_host(s)) set_host_physics_extras(*s.host_extras);
|
||||
else set_host_physics_extras({});
|
||||
if (s.mode == Mode::host) {
|
||||
physics_tuning::release(s.base);
|
||||
if (!s.enforce_tuning) {
|
||||
s.sent_tuning.reset();
|
||||
s.tuning_packet.clear();
|
||||
s.sent_extras = 0;
|
||||
s.extras_packet.clear();
|
||||
return;
|
||||
}
|
||||
// The host's physics beyond its tuning: small, so a look four times a second, and sent
|
||||
// whenever they change (the first time even when they are the game's own, so a guest
|
||||
// never keeps what an earlier spell of enforcement left it).
|
||||
if (now >= s.next_extras_check) {
|
||||
s.next_extras_check = now + 250000;
|
||||
std::vector<std::uint8_t> extras;
|
||||
if (local_physics_extras(s.sent_extras, extras)) {
|
||||
if (extras.size() > max_physics_extras) extras.clear();
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Multiplayer: sending your other physics changes to guests ({} bytes).", extras.size());
|
||||
auto p = packet(s, PacketKind::physics_extras, now);
|
||||
p.extras = std::move(extras);
|
||||
s.extras_packet = encode_wire(p);
|
||||
broadcast(s, p, true, false, now);
|
||||
}
|
||||
}
|
||||
// Edits to the tuning are rare: a look every 5 s (a 17 KB copy) is enough.
|
||||
if (now < s.next_tuning_check) return;
|
||||
s.next_tuning_check = now + 5000000;
|
||||
@@ -530,7 +638,14 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui
|
||||
};
|
||||
if (now - s.last_cosmetic_capture >= 500000) {
|
||||
s.last_cosmetic_capture = now;
|
||||
const auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status);
|
||||
auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status);
|
||||
// The player's choices to go without their tag or their animated items travel with their
|
||||
// outfit, so a change is sent like one and reaches players who join later.
|
||||
if (appearance) {
|
||||
appearance->hide_tag = !own_tag_shown();
|
||||
appearance->hide_items = !own_items_shown();
|
||||
appearance->marks = developer_hoodie_detail::own_styles.load();
|
||||
}
|
||||
if (appearance && (!s.sent_appearance || *appearance != *s.sent_appearance)) {
|
||||
auto p = packet(s, PacketKind::cosmetics, now);
|
||||
p.appearance = *appearance;
|
||||
@@ -552,11 +667,34 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui
|
||||
if (captured_at) p.time_us = captured_at;
|
||||
p.pose = local.pose;
|
||||
++s.local_pose_count;
|
||||
if (s.pose_dump.is_open()) {
|
||||
// A record: when it was captured (8 bytes), its length (4), and the compact encoding.
|
||||
const auto raw = encode(p, true);
|
||||
const std::uint64_t time = p.time_us;
|
||||
const auto length = static_cast<std::uint32_t>(raw.size());
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
++s.pose_dump_count;
|
||||
if (now >= s.pose_dump_until) {
|
||||
s.pose_dump.close();
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: pose dump finished, {} poses.", s.pose_dump_count);
|
||||
}
|
||||
}
|
||||
deliver(p, false, true);
|
||||
auto samples = drain_audio_capture(now);
|
||||
if (!samples.empty()) {
|
||||
auto a = packet(s, PacketKind::audio, now);
|
||||
a.audio = std::move(samples);
|
||||
if (s.pose_dump.is_open()) {
|
||||
// The skater's sound is recorded with the poses, in the same records.
|
||||
const auto raw = encode(a, true);
|
||||
const std::uint64_t time = a.time_us;
|
||||
const auto length = static_cast<std::uint32_t>(raw.size());
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&time), sizeof time);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(&length), sizeof length);
|
||||
s.pose_dump.write(reinterpret_cast<const char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
}
|
||||
deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ void load_host_preferences(Session &s) {
|
||||
p.voice_range = value->get<float>();
|
||||
if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast<std::uint64_t>(*value)))
|
||||
p.placement = static_cast<ObjectPlacement>(*value);
|
||||
if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast<std::uint64_t>(*value)))
|
||||
p.object_limit = static_cast<unsigned>(*value);
|
||||
p.guest_noclip = profile_runtime::local_preference("Host.GuestNoclip").value_or(true);
|
||||
p.guest_no_bail = profile_runtime::local_preference("Host.GuestNoBail").value_or(true);
|
||||
p.guest_boosts = profile_runtime::local_preference("Host.GuestBoosts").value_or(true);
|
||||
@@ -70,6 +72,7 @@ void save_host_preferences(const Session &s) {
|
||||
{"Host.Distance.HalfReturn", static_cast<std::int64_t>(p.distances.half_rate_return)},
|
||||
{"Host.Distance.LowStart", static_cast<std::int64_t>(p.distances.low_rate_start)},
|
||||
{"Host.ObjectPlacement", static_cast<std::int64_t>(p.placement)},
|
||||
{"Host.ObjectLimit", static_cast<std::int64_t>(p.object_limit)},
|
||||
{"Host.GuestNoclip", p.guest_noclip},
|
||||
{"Host.GuestNoBail", p.guest_no_bail},
|
||||
{"Host.GuestBoosts", p.guest_boosts},
|
||||
@@ -147,6 +150,9 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.tps = s.tps;
|
||||
view.distances = s.distances;
|
||||
view.object_placement = s.object_placement;
|
||||
view.object_limit = s.object_limit;
|
||||
view.object_limit_own = s.mode == Mode::host || s.server_admin ? 0 : s.object_limit; // as apply_object_limit gives this game
|
||||
view.objects_placed = s.mode == Mode::off ? 0 : static_cast<unsigned>(s.local_objects.objects().size());
|
||||
view.guest_noclip = s.guest_noclip;
|
||||
view.guest_no_bail = s.guest_no_bail;
|
||||
view.guest_boosts = s.guest_boosts;
|
||||
@@ -167,12 +173,38 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.lobby_name = s.lobby_name;
|
||||
// A dedicated server's roster capacity counts the server itself.
|
||||
view.capacity = static_cast<int>(dedicated_host(s) ? s.capacity - 1 : s.capacity);
|
||||
view.party_overlay = s.party_overlay;
|
||||
load_host_preferences(s);
|
||||
view.saved_host = {true, s.host_preferences.public_lobby, s.host_preferences.password_required,
|
||||
static_cast<int>(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name};
|
||||
view.nametags = s.nametags;
|
||||
view.custom_nametags = s.custom_nametags;
|
||||
if (const auto social = steam_social_snapshot())
|
||||
if (const auto mark = identity_mark(social->local.id)) {
|
||||
std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark);
|
||||
view.identity_animation =
|
||||
*mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" :
|
||||
*mark == IdentityList::centrix ? "BLUE" : *mark == IdentityList::staff ? "GREEN" : "GOLD";
|
||||
// A developer's standard is the rainbow already.
|
||||
view.identity_rainbow = *mark == IdentityList::staff;
|
||||
const auto styles = developer_hoodie_detail::own_styles.load();
|
||||
const auto standard = developer_hoodie_detail::standard_picks(*mark);
|
||||
view.identity_styles.resize(styles.size());
|
||||
for (std::size_t i = 0; i < styles.size(); ++i) {
|
||||
// A cosmetic never given colours shows its list's own in the pickers.
|
||||
// The rainbow has no colours of its own to show either.
|
||||
const bool rainbow = view.identity_rainbow && rainbow_style(styles[i]);
|
||||
const bool picked = !rainbow && (styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid ||
|
||||
styles[i].from != styles[i].to || styles[i].from != std::array<std::uint8_t, 3>{});
|
||||
const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second;
|
||||
auto &shown = view.identity_styles[i];
|
||||
shown.name = mark_item_names[i];
|
||||
shown.mode = rainbow ? 4 : static_cast<int>(styles[i].mode);
|
||||
shown.speed = styles[i].speed;
|
||||
for (std::size_t part = 0; part < 3; ++part)
|
||||
shown.from[part] = static_cast<float>(from[part]) / 255.f, shown.to[part] = static_cast<float>(to[part]) / 255.f;
|
||||
}
|
||||
}
|
||||
view.identity_tag_shown = own_tag_shown();
|
||||
view.identity_items_shown = own_items_shown();
|
||||
load_host_preferences(s);
|
||||
view.voice_range = s.mode == Mode::host ? s.voice_range
|
||||
: dedicated_host(s) ? s.roster_voice_range : s.host_preferences.voice_range;
|
||||
@@ -181,7 +213,7 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.party = s.local_party;
|
||||
view.party_leader = s.local_party_leader;
|
||||
view.party_open = s.local_party_open;
|
||||
view.parties = view.dedicated;
|
||||
view.parties = s.mode == Mode::host || s.mode == Mode::join;
|
||||
for (const auto &invite : s.party_invites) {
|
||||
const auto *from = find_peer(s, invite.from);
|
||||
view.party_invites.push_back({invite.from, from && !from->member.name.empty() ? from->member.name
|
||||
@@ -192,9 +224,22 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.server_bans = true;
|
||||
view.server_ban_total = s.server_ban_total;
|
||||
view.server_maps = s.server_maps;
|
||||
view.server_map_pool = s.server_map_pool;
|
||||
view.server_map_rotation = s.server_map_rotation;
|
||||
view.server_map_votes = (s.server_votes & server_vote_map) != 0;
|
||||
}
|
||||
view.player_distance = s.player_distance;
|
||||
view.prefer_direct = s.prefer_direct;
|
||||
view.nametag_distance = s.nametag_distance;
|
||||
view.nametag_dots = s.nametag_dots;
|
||||
view.nametags_friends = s.nametags_friends;
|
||||
view.chat_visible = s.chat_visible;
|
||||
view.chat_filter = s.chat_filter;
|
||||
view.chat_bubbles = s.chat_bubbles;
|
||||
view.chat_bubbles_own = s.chat_bubbles_own;
|
||||
view.chat_bubbles_distance = s.chat_bubbles_distance;
|
||||
view.chat_bubbles_duration = s.chat_bubbles_duration;
|
||||
view.chat_bubbles_history = s.chat_bubbles_history;
|
||||
view.object_status = network_object_status();
|
||||
view.players = static_cast<int>(player_count(s));
|
||||
view.sent = t.sent;
|
||||
@@ -230,7 +275,16 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.browser_status = lobby.browser;
|
||||
// Dedicated servers first: they are always up and never a stranger's own session.
|
||||
view.lobbies = s.servers.rows();
|
||||
// Looked up as the list is shown: the team's list can arrive, or change, after a server was found.
|
||||
for (auto &server : view.lobbies) server.official = official_server(server.id);
|
||||
view.lobbies.insert(view.lobbies.end(), lobby.rows.begin(), lobby.rows.end());
|
||||
// Steam friends by the public server or lobby their game says they are in.
|
||||
if (const auto social = steam_social_snapshot())
|
||||
for (const auto &player : social->friends) {
|
||||
if (!player.session) continue;
|
||||
const auto row = std::find_if(view.lobbies.begin(), view.lobbies.end(), [&](const auto &entry) { return entry.id == player.session; });
|
||||
if (row != view.lobbies.end() && row->friends.size() < 16) row->friends.push_back(player.name.empty() ? std::string("A friend") : player.name);
|
||||
}
|
||||
view.status = s.status;
|
||||
view.native_status = s.native_status;
|
||||
view.audio_captured = captured_audio_frames();
|
||||
@@ -395,7 +449,7 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
||||
{"/tp", "/tp <player>", "Teleport beside a player (or /tp <x> <y> <z>)", "player"},
|
||||
{"/p", "/p <message>", "Talk to your party only"},
|
||||
};
|
||||
if (dedicated_host(s)) {
|
||||
if (s.mode == Mode::host || s.mode == Mode::join) {
|
||||
list.push_back({"/party invite", "/party invite <player>", "Invite a player to your party", "player"});
|
||||
list.push_back({"/party accept", "/party accept [player]", "Join the party you were invited to", "player"});
|
||||
list.push_back({"/party decline", "/party decline [player]", "Turn down a party invite", "player"});
|
||||
@@ -407,6 +461,9 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
||||
list.push_back({"/party", "/party", "Who is in your party"});
|
||||
}
|
||||
if (dedicated_host(s)) {
|
||||
// The server's own commands (server_votes.cpp, server_host.cpp): it answers them, and
|
||||
// this list is only what the "/" menu offers, so one left out here still works unseen.
|
||||
list.push_back({"/w", "/w <player> <message>", "Send a player a private message", "player"});
|
||||
if (s.server_votes & server_vote_map) list.push_back({"/vote map", "/vote map <map>", "Start a vote to change the map", "map"});
|
||||
if (s.server_votes & server_vote_kick)
|
||||
list.push_back({"/vote kick", "/vote kick <player>", "Start a vote to kick a player", "player"});
|
||||
@@ -417,6 +474,9 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
||||
list.push_back({"/no", "/no", "Vote no in the running vote"});
|
||||
}
|
||||
if (s.server_admin) {
|
||||
list.push_back({"/msg", "/msg <player> <message>", "Admin: message a player privately", "player"});
|
||||
list.push_back({"/msg-party", "/msg-party <player> <message>", "Admin: message everyone in a player's party", "player"});
|
||||
list.push_back({"/msg-admins", "/msg-admins <message>", "Admin: message the admins who are on"});
|
||||
list.push_back({"/kick", "/kick <player>", "Admin: kick a player until the server restarts", "player"});
|
||||
list.push_back({"/ban", "/ban <player>", "Admin: ban a player", "player"});
|
||||
list.push_back({"/map", "/map <map>", "Admin: change the server's map", "map"});
|
||||
@@ -425,6 +485,8 @@ std::vector<MultiplayerChatCommand> chat_commands(const Session &s) {
|
||||
list.push_back({"/tod", "/tod <time>", "Admin: set the time of day", "time"});
|
||||
list.push_back({"/votes", "/votes [map|kick|tod on|off|<percent>]", "Admin: the server's vote settings"});
|
||||
list.push_back({"/vote-cancel", "/vote-cancel", "Admin: stop the running vote"});
|
||||
list.push_back({"/map-pool", "/map-pool [add|remove <map>|clear]", "Admin: the maps players vote between and the rotation uses"});
|
||||
list.push_back({"/rotation", "/rotation [<minutes>|off]", "Admin: change the map on a timer, through the map pool"});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
@@ -496,31 +558,53 @@ void publish_chat(Session &s) {
|
||||
auto [found, added] = s.chat_masked.try_emplace(line.sequence);
|
||||
if (added) found->second = {text::mask_bad_words(line.name), text::mask_bad_words(line.text)};
|
||||
line.name = found->second.first;
|
||||
line.text = found->second.second;
|
||||
if (found->second.second != line.text) line.unmasked = std::exchange(line.text, found->second.second);
|
||||
}
|
||||
}
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.chat_view = std::move(view);
|
||||
}
|
||||
// The colour and tag a player gets, in chat and on their nametag.
|
||||
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t sender, bool local) {
|
||||
std::pair<std::uint32_t, std::string> mark_role(IdentityList list) {
|
||||
switch (list) {
|
||||
case IdentityList::developer: return {nametag_developer, "Dev"};
|
||||
case IdentityList::content_creator: return {nametag_creator, "Creator"};
|
||||
case IdentityList::centrix: return {nametag_centrix, "Centrix"};
|
||||
case IdentityList::staff: return {nametag_staff, "Staff"};
|
||||
default: return {nametag_homie, "Homie"};
|
||||
}
|
||||
}
|
||||
// The colour and tag a player gets, in chat and on their nametag. `marks` off leaves out who
|
||||
// the backend says they are: for a chat line that is not known to be theirs (chat proofs,
|
||||
// session_receive.cpp).
|
||||
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t sender, bool local, bool marks) {
|
||||
if (!sender) return {};
|
||||
const bool dedicated = dedicated_host(s);
|
||||
if (dedicated && sender == s.host_id) return {nametag_admin, {}}; // the server itself
|
||||
if (reskate_developer(sender)) return {nametag_developer, "Dev"};
|
||||
if (dedicated && sender == s.host_id) return {s.server_chat_badge, "Server"}; // the server itself
|
||||
const auto *peer = local ? nullptr : find_peer(s, sender);
|
||||
const bool vouched = local || (peer && steam_vouched(s, *peer));
|
||||
// Who the backend says a player is comes before what they are in this lobby, unless they
|
||||
// have turned their tag off (the Special page), which their appearance tells everyone.
|
||||
if (marks && vouched && (local ? own_tag_shown() : shows_tag(*peer)))
|
||||
if (const auto mark = identity_mark(sender)) return mark_role(*mark);
|
||||
if (dedicated && (local ? s.server_admin : peer && peer->member.admin)) return {nametag_admin, "Admin"};
|
||||
if (!dedicated && (local ? s.mode == Mode::host : sender == s.host_id)) return {nametag_host, "Host"};
|
||||
if (!local) {
|
||||
if (!local && vouched) {
|
||||
refresh_friends(s);
|
||||
if (std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), sender)) return {nametag_friend, "Friend"};
|
||||
}
|
||||
return {nametag_white, {}};
|
||||
}
|
||||
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local) {
|
||||
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local, bool marks) {
|
||||
if (name.empty()) name = sender ? "Player" : "ReSkate";
|
||||
auto [color, tag] = player_role(s, sender, local);
|
||||
s.chat.push_back({++s.chat_sequence, sender, std::move(name), std::move(text), local, color, std::move(tag)});
|
||||
auto [color, tag] = player_role(s, sender, local, marks);
|
||||
// The dedicated server's own lines (its chat, and its answers sent to this player alone) stand out.
|
||||
const bool server = dedicated_host(s) && (sender ? sender == s.host_id : name == "Server");
|
||||
if (server && !sender) {
|
||||
color = s.server_chat_badge;
|
||||
tag = "Server";
|
||||
}
|
||||
s.chat.push_back({++s.chat_sequence, sender, now_us(), std::move(name), std::move(text), local, color, std::move(tag), {}, server,
|
||||
server ? s.server_chat_text : 0U});
|
||||
while (s.chat.size() > multiplayer_chat_history) s.chat.pop_front();
|
||||
publish_chat(s);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ struct Api {
|
||||
LobbyCallback lobby_callback;
|
||||
PresenceCallback presence_callback;
|
||||
std::uint64_t next_open{}, next_presence{};
|
||||
std::string owned, previous;
|
||||
std::string owned, previous, session;
|
||||
bool reported_error{};
|
||||
void open() {
|
||||
const auto loaded = GetModuleHandleW(L"steam_api64.dll");
|
||||
@@ -139,6 +139,10 @@ void tick_steam_friend_join() noexcept {
|
||||
if (now >= api->next_presence) {
|
||||
api->next_presence = now + 2000;
|
||||
api->presence(steam_join_presence(model()));
|
||||
// Only written when it changes; cleared (an empty value removes the key) on leaving.
|
||||
if (auto session = steam_session_presence(model()); session != api->session &&
|
||||
api->set(api->friends, steam_session_key.data(), session.c_str()))
|
||||
api->session = std::move(session);
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
if (!api->reported_error) {
|
||||
|
||||
@@ -19,6 +19,21 @@ inline std::string steam_join_presence(const MultiplayerModel& model) {
|
||||
model.players >= model.capacity || (model.hosting ? !model.public_host || !model.lobby_listed : !model.connected)) return {};
|
||||
return std::string(steam_join_prefix) + std::to_string(model.public_lobby);
|
||||
}
|
||||
// The public server or lobby the player is in, told to Steam friends so their server lists
|
||||
// can show who is where. Unlike the join offer above it is set in a full or password-protected
|
||||
// session too; a private (code-only) session is never told. The value is the listing's id.
|
||||
inline constexpr std::string_view steam_session_key = "reskate_session";
|
||||
inline std::optional<std::uint64_t> steam_session_target(std::string_view text) {
|
||||
std::uint64_t id{};
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id);
|
||||
if (text.empty() || parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || !id) return {};
|
||||
return id;
|
||||
}
|
||||
inline std::string steam_session_presence(const MultiplayerModel& model) {
|
||||
if (!model.active || model.echo || !model.public_lobby || (model.hosting ? !model.public_host || !model.lobby_listed : !model.connected))
|
||||
return {};
|
||||
return std::to_string(model.public_lobby);
|
||||
}
|
||||
// Optional Steam integration, ticked on the client thread. Steam callbacks only
|
||||
// enqueue requests; normal lobby verification runs before opening any P2P link.
|
||||
void tick_steam_friend_join() noexcept;
|
||||
|
||||
@@ -53,6 +53,10 @@ inline std::uint64_t lane_queue_time(const SteamNetConnectionRealTimeStatus_t &i
|
||||
if (time == std::numeric_limits<SteamNetworkingMicroseconds>::max()) return 0;
|
||||
return static_cast<std::uint64_t>(std::max<SteamNetworkingMicroseconds>(0, time));
|
||||
}
|
||||
// A whole connection's queue time, with the same care for Steam's "cannot estimate" value.
|
||||
inline std::uint64_t queue_time(const SteamNetConnectionRealTimeStatus_t &info) {
|
||||
return lane_queue_time(info, {}, TrafficLane::gameplay);
|
||||
}
|
||||
inline bool lane_congested(const SteamNetConnectionRealTimeStatus_t &info,
|
||||
std::span<const SteamNetConnectionRealTimeLaneStatus_t> lanes, TrafficLane lane) {
|
||||
if (info.m_eState != k_ESteamNetworkingConnectionState_Connected) return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "steam_server_browser.h"
|
||||
#include "Extension/Multiplayer/Net/protocol.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Text/word_filter.h"
|
||||
#include <Windows.h>
|
||||
@@ -85,6 +86,17 @@ std::string bounded(const char *text, std::size_t size) {
|
||||
while (length < size && text[length]) ++length;
|
||||
return std::string(text, length);
|
||||
}
|
||||
// A listing's text as it is shown: whole UTF-8 characters, no control characters, at most
|
||||
// `limit` bytes.
|
||||
std::string tidy(std::string_view text, std::size_t limit) {
|
||||
auto result = clean_chat_text(text);
|
||||
if (result.size() > limit) {
|
||||
auto cut = limit;
|
||||
while (cut && (static_cast<unsigned char>(result[cut]) & 0xC0) == 0x80) --cut;
|
||||
result.resize(cut);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<MultiplayerLobby> read_server_tags(std::string_view tags, std::uint64_t steam_id) {
|
||||
@@ -115,14 +127,43 @@ std::optional<MultiplayerLobby> read_server_tags(std::string_view tags, std::uin
|
||||
case 'p': if (!integer(value, row.players)) row.players = 0; break;
|
||||
case 'c': if (!integer(value, row.capacity)) row.capacity = 0; break;
|
||||
case 'w': row.password_required = value == "1"; break;
|
||||
case 'd': {
|
||||
// A port, or "a.b.c.d:port" when the server is reached at another address than it is listed under.
|
||||
const auto colon = value.find(':');
|
||||
std::uint32_t address{};
|
||||
bool ok = true;
|
||||
if (colon != std::string_view::npos) {
|
||||
auto rest = value.substr(0, colon);
|
||||
unsigned parts{};
|
||||
while (ok && parts < 4) {
|
||||
const auto dot = rest.find('.');
|
||||
int part{};
|
||||
ok = integer(rest.substr(0, dot), part) && part >= 0 && part <= 255;
|
||||
address = (address << 8) | static_cast<std::uint32_t>(part & 255);
|
||||
++parts;
|
||||
if (dot == std::string_view::npos) break;
|
||||
rest = rest.substr(dot + 1);
|
||||
}
|
||||
ok = ok && parts == 4 && address;
|
||||
}
|
||||
const auto port = colon == std::string_view::npos ? value : value.substr(colon + 1);
|
||||
if (!ok || !integer(port, row.direct_port) || row.direct_port < 1 || row.direct_port > 65535) row.direct_port = 0;
|
||||
else row.direct_ip = address; // 0: the address the server is listed under
|
||||
break;
|
||||
}
|
||||
case 'm': row.map = std::string(value); break;
|
||||
// The name is last and may itself contain anything but commas.
|
||||
case 'n': row.name = std::string(value) + (tags.empty() ? "" : "," + std::string(tags)); tags = {}; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (!reskate || !version || !secret || row.capacity < 1) return {};
|
||||
// Anyone can list a server: a row's numbers are held to what a server can have, and its
|
||||
// text is cleaned (tidy) before it is shown or logged.
|
||||
if (!reskate || !version || !secret || row.capacity < 1 || row.capacity > static_cast<int>(max_remote_players)) return {};
|
||||
row.players = std::clamp(row.players, 0, row.capacity);
|
||||
row.code = format_invite({steam_id, secret});
|
||||
row.name = tidy(row.name, 64);
|
||||
row.map = tidy(row.map, 128);
|
||||
if (row.name.empty()) row.name = "ReSkate server";
|
||||
return row;
|
||||
}
|
||||
@@ -142,10 +183,18 @@ const MultiplayerLobby *SteamServerBrowser::find(std::uint64_t id) const {
|
||||
}
|
||||
void SteamServerBrowser::refresh(std::uint64_t now) {
|
||||
if (!api().open()) return;
|
||||
// Steam stops answering a game that asks often (an empty list, for minutes): one search at a
|
||||
// time, a quarter of a minute apart, and three quarters after one that came back empty.
|
||||
// Opening or refreshing the browser sooner shows what the last search found.
|
||||
if (started_ && (!searches_.empty() || now - started_ < (internet_listed_ ? 15000000 : 45000000))) return;
|
||||
servers_ = api().servers();
|
||||
if (!servers_) return;
|
||||
release();
|
||||
found_.clear();
|
||||
// What the last search found stays until this one has an internet list of its own; what
|
||||
// it says about a server replaces the old copy.
|
||||
++search_;
|
||||
internet_listed_ = false;
|
||||
for (auto &[id, entry] : found_) entry.answered = false;
|
||||
// The internet list, filtered by Steam on the tags; and the LAN list, whose
|
||||
// servers answer directly (live map and ping) and show up at once.
|
||||
static Filter filter{"gametagsand", {}};
|
||||
@@ -156,27 +205,32 @@ void SteamServerBrowser::refresh(std::uint64_t now) {
|
||||
auto *response = new Response;
|
||||
void *request = lan ? (api().lan ? api().lan(servers_, skate_app, response) : nullptr)
|
||||
: api().request(servers_, skate_app, filters, 1, response);
|
||||
if (request) searches_.push_back({request, response});
|
||||
if (request) searches_.push_back({request, response, !lan});
|
||||
else delete response;
|
||||
}
|
||||
started_ = now;
|
||||
next_poll_ = now;
|
||||
}
|
||||
void SteamServerBrowser::read() {
|
||||
void SteamServerBrowser::read(std::uint64_t now) {
|
||||
for (const auto &search : searches_) {
|
||||
const int count = api().count(servers_, search.request);
|
||||
if (search.internet && count > 0) internet_listed_ = true;
|
||||
for (int i = 0; i < count && i < 512; ++i) {
|
||||
const auto *item = api().details(servers_, search.request, i);
|
||||
if (!item) continue;
|
||||
auto row = read_server_tags(bounded(item->tags, sizeof item->tags), item->steam_id.value);
|
||||
if (!row) continue;
|
||||
if (row->direct_port && !row->direct_ip) row->direct_ip = item->ip; // the address Steam lists the server at
|
||||
auto &entry = found_[row->id];
|
||||
entry.search = search_;
|
||||
entry.listed = now;
|
||||
if (!search.internet) entry.lan = true;
|
||||
// A direct answer is live; the tags come from Steam's master list, which
|
||||
// can lag behind a change of map.
|
||||
const bool answered = item->had_successful_response;
|
||||
if (answered) {
|
||||
if (const auto name = bounded(item->name, sizeof item->name); !name.empty()) row->name = name;
|
||||
if (const auto map = bounded(item->map, sizeof item->map); !map.empty()) row->map = map;
|
||||
if (const auto name = tidy(bounded(item->name, sizeof item->name), 64); !name.empty()) row->name = name;
|
||||
if (const auto map = tidy(bounded(item->map, sizeof item->map), 128); !map.empty()) row->map = map;
|
||||
row->ping = item->ping;
|
||||
}
|
||||
if (answered || !entry.answered) {
|
||||
@@ -188,14 +242,18 @@ void SteamServerBrowser::read() {
|
||||
entry.addresses.push_back(address);
|
||||
}
|
||||
}
|
||||
// A restarted server gets a new, higher Steam ID, while Steam lists the old
|
||||
// one for a while. Only one can hold an address and port: keep the newest.
|
||||
// Servers named with bad words are never shown (they refuse to list themselves too).
|
||||
// A restarted anonymous server gets a new, higher Steam ID, while Steam lists the old
|
||||
// one for a while. Only one can hold an address and port: keep the newest, or the one
|
||||
// signed in with a login token (its ID never changes, and is lower than any anonymous one).
|
||||
// Servers named with bad words, or with characters a server name cannot have, are
|
||||
// never shown (a server refuses such a name too, but anyone can list one).
|
||||
std::vector<MultiplayerLobby> rows;
|
||||
for (const auto &[id, entry] : found_) {
|
||||
if (text::contains_bad_words(entry.row.name)) continue;
|
||||
if (!valid_server_name(entry.row.name) || text::contains_bad_words(entry.row.name)) continue;
|
||||
if (blocked_server(id) || (server_tokens_required() && !persistent_server_steam_id(id) && !entry.lan)) continue;
|
||||
const bool replaced = std::any_of(found_.begin(), found_.end(), [&](const auto &other) {
|
||||
return other.first > id && std::any_of(entry.addresses.begin(), entry.addresses.end(), [&](const auto &a) {
|
||||
const bool kept = persistent_server_steam_id(id), other_kept = persistent_server_steam_id(other.first);
|
||||
return (kept != other_kept ? other_kept : other.first > id) && std::any_of(entry.addresses.begin(), entry.addresses.end(), [&](const auto &a) {
|
||||
return std::find(other.second.addresses.begin(), other.second.addresses.end(), a) != other.second.addresses.end();
|
||||
});
|
||||
});
|
||||
@@ -206,17 +264,30 @@ void SteamServerBrowser::read() {
|
||||
void SteamServerBrowser::tick(std::uint64_t now) {
|
||||
if (searches_.empty() || now < next_poll_) return;
|
||||
next_poll_ = now + 250000;
|
||||
read();
|
||||
read(now);
|
||||
// Pings of servers behind a NAT never answer; stop waiting after 15 s.
|
||||
// Steam can hold an internet search back for seconds before it starts (it does when another
|
||||
// was made shortly before), and it is not "refreshing" while it waits: an internet search
|
||||
// with nothing listed yet is given six seconds before that counts as finished.
|
||||
const bool done = std::all_of(searches_.begin(), searches_.end(), [&](const auto &search) {
|
||||
return static_cast<Response *>(search.response)->done ||
|
||||
(now - started_ > 1000000 && !api().refreshing(servers_, search.request));
|
||||
if (static_cast<Response *>(search.response)->done) return true;
|
||||
const std::uint64_t patience = search.internet && !internet_listed_ ? 6000000 : 1000000;
|
||||
return now - started_ > patience && !api().refreshing(servers_, search.request);
|
||||
}) || now - started_ > 15000000;
|
||||
if (done) {
|
||||
release();
|
||||
// An internet list with servers in it is the whole truth: what it left out is gone.
|
||||
// An empty one is Steam not answering (see the header), and it has stayed that way for
|
||||
// minutes for a player who searched often: keep what was listed in the last quarter
|
||||
// of an hour. A server that closed meanwhile only fails to join.
|
||||
const auto before = found_.size();
|
||||
std::erase_if(found_, [&](const auto &entry) {
|
||||
return entry.second.search != search_ && (internet_listed_ || now - entry.second.listed > 900000000);
|
||||
});
|
||||
if (found_.size() != before) read(now);
|
||||
std::string list;
|
||||
for (const auto &row : rows_)
|
||||
list += (list.empty() ? ": " : "; ") + row.name + " (" + row.map + ", " + std::to_string(row.players) + "/" +
|
||||
list += (list.empty() ? ": " : "; ") + row.name + (official_server(row.id) ? " [official]" : "") + " (" + row.map + ", " + std::to_string(row.players) + "/" +
|
||||
std::to_string(row.capacity) + (row.ping >= 0 ? ", " + std::to_string(row.ping) + " ms" : std::string{}) + ")";
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Server browser: {} ReSkate server{} found{}.",
|
||||
rows_.size(), rows_.size() == 1 ? "" : "s", list);
|
||||
|
||||
@@ -15,6 +15,12 @@ std::optional<MultiplayerLobby> read_server_tags(std::string_view tags, std::uin
|
||||
// Dedicated ReSkate servers from Steam's server list for Skate. Rows come from
|
||||
// Steam's master list, so a server shows even when a NAT hides its query port;
|
||||
// what a server answers directly (name, ping) replaces the tag copy.
|
||||
// Not shown: a server the ReSkate team has blocked, and, while the team requires login
|
||||
// tokens (developer_identity.h), one that signed in to Steam without one, unless it is on
|
||||
// the player's own network.
|
||||
// Steam answers a search made soon after another with an empty internet list (seen
|
||||
// 2026-10-06: every refresh within about 40 s of a full one). A search that lists no
|
||||
// internet server therefore keeps the servers the last one found, for a while.
|
||||
class SteamServerBrowser {
|
||||
public:
|
||||
~SteamServerBrowser();
|
||||
@@ -27,19 +33,24 @@ class SteamServerBrowser {
|
||||
private:
|
||||
struct Search {
|
||||
void *request{}, *response{};
|
||||
bool internet{};
|
||||
};
|
||||
struct Found {
|
||||
MultiplayerLobby row;
|
||||
// Every address it was listed at: its public one, and a LAN one nearby.
|
||||
std::vector<std::pair<std::uint32_t, std::uint16_t>> addresses;
|
||||
bool answered{};
|
||||
std::uint64_t search{}, listed{}; // the search that last listed it, and when
|
||||
bool lan{}; // answered on the player's own network
|
||||
};
|
||||
void *servers_{};
|
||||
std::vector<Search> searches_; // the internet and LAN lists
|
||||
std::map<std::uint64_t, Found> found_;
|
||||
std::uint64_t started_{}, next_poll_{};
|
||||
std::uint64_t search_{}; // counts refreshes
|
||||
bool internet_listed_{}; // this search's internet list had a server in it
|
||||
std::vector<MultiplayerLobby> rows_;
|
||||
void release();
|
||||
void read();
|
||||
void read(std::uint64_t now);
|
||||
};
|
||||
} // namespace dingosdk::multiplayer
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "steam_social.h"
|
||||
#include "steam_friend_join.h"
|
||||
#include "Engine/Core/Platform/launcher_support.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
@@ -37,6 +38,7 @@ struct Api {
|
||||
int (*presence)(void *, std::uint64_t){};
|
||||
struct Game { std::uint64_t id{}; std::uint32_t ip{}; std::uint16_t port{}, query{}; std::uint64_t lobby{}; };
|
||||
bool (*game)(void *, std::uint64_t, Game *){};
|
||||
const char *(*rich)(void *, std::uint64_t, const char *){};
|
||||
void load() {
|
||||
const auto loaded = GetModuleHandleW(L"steam_api64.dll");
|
||||
if (!loaded || loaded == module) return;
|
||||
@@ -52,7 +54,7 @@ struct Api {
|
||||
#define BIND(member, suffix) member = symbol<decltype(member)>(loaded, "SteamAPI_ISteamFriends_" suffix)
|
||||
BIND(self_name, "GetPersonaName"); BIND(count, "GetFriendCount"); BIND(at, "GetFriendByIndex");
|
||||
BIND(friend_name, "GetFriendPersonaName"); BIND(presence, "GetFriendPersonaState");
|
||||
BIND(game, "GetFriendGamePlayed");
|
||||
BIND(game, "GetFriendGamePlayed"); BIND(rich, "GetFriendRichPresence");
|
||||
#undef BIND
|
||||
module = loaded;
|
||||
}
|
||||
@@ -138,7 +140,14 @@ std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot() {
|
||||
const auto presence = api.presence(refresh.friends, id);
|
||||
Api::Game game;
|
||||
const bool playing = api.game(refresh.friends, id, &game) && (game.id & 0xffffff) == 3354750;
|
||||
result.friends.push_back({id, name(api.friend_name(refresh.friends, id)), presence > 0 && presence < 7, playing});
|
||||
// Where they skate: what their game says for this, or (a build before it did) the
|
||||
// joinable session it offers friends.
|
||||
std::uint64_t session{};
|
||||
if (playing) {
|
||||
session = steam_session_target(name(api.rich(refresh.friends, id, steam_session_key.data()))).value_or(0);
|
||||
if (!session) session = steam_join_target(name(api.rich(refresh.friends, id, "connect"))).value_or(0);
|
||||
}
|
||||
result.friends.push_back({id, name(api.friend_name(refresh.friends, id)), presence > 0 && presence < 7, playing, session});
|
||||
}
|
||||
if (refresh.index < refresh.count) return current;
|
||||
// The list changed during the pass, so an index may have shifted past a friend:
|
||||
|
||||
@@ -9,6 +9,10 @@ struct SteamSocialPlayer {
|
||||
std::uint64_t id{};
|
||||
std::string name;
|
||||
bool online{}, playing{};
|
||||
// The public server or lobby a friend is skating in (its browser row's id), when they
|
||||
// are in one: what their game tells Steam friends (steam_friend_join.h). 0: none, a
|
||||
// private session, or a build that does not say.
|
||||
std::uint64_t session{};
|
||||
bool operator==(const SteamSocialPlayer &) const = default;
|
||||
};
|
||||
struct SteamSocialSnapshot {
|
||||
@@ -18,5 +22,6 @@ struct SteamSocialSnapshot {
|
||||
};
|
||||
// Read the game's existing Steam session. Never initializes Steam, changes
|
||||
// presence, or sends friend/invite requests. Poll only on the client thread.
|
||||
// The local player has no `session` here: the session model knows theirs.
|
||||
std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot();
|
||||
}
|
||||
@@ -79,6 +79,7 @@ struct SteamTransport::Impl {
|
||||
TransportStatus state;
|
||||
SteamLanes lanes;
|
||||
HSteamListenSocket listener{};
|
||||
HSteamListenSocket direct_listener{}; // a dedicated server's, on its own address (listen_direct)
|
||||
struct Link {
|
||||
HSteamNetConnection handle{};
|
||||
ULONGLONG connecting_since{};
|
||||
@@ -92,8 +93,82 @@ struct SteamTransport::Impl {
|
||||
std::uint64_t status_frame{};
|
||||
bool status_ok{};
|
||||
bool recheck{}; // a status callback named this connection: read its state this poll
|
||||
bool direct{}; // straight to or from an address, not through Steam's relays
|
||||
};
|
||||
std::map<std::uint64_t, Link> links;
|
||||
// How the connections Steam closed ended (take_closed). A handful at most wait here.
|
||||
std::map<std::uint64_t, std::string> closed;
|
||||
// What each connection may send a second, and Steam's call that changes an open one.
|
||||
int send_rate = connection_send_rate, listen_rate = connection_send_rate; // now, and when the listen socket opened
|
||||
void *utils{};
|
||||
bool (*set_config)(void *, ESteamNetworkingConfigValue, ESteamNetworkingConfigScope, intptr_t,
|
||||
ESteamNetworkingConfigDataType, const void *){};
|
||||
// Steam's SteamRelayNetworkStatus_t, which the bundled header only names: how its relay
|
||||
// network looks from here. Room to spare after the message, should Steam's grow.
|
||||
struct RelayNetwork {
|
||||
ESteamNetworkingAvailability available{};
|
||||
int measuring{};
|
||||
ESteamNetworkingAvailability config{}, any_relay{};
|
||||
char message[256]{};
|
||||
char spare[256]{};
|
||||
};
|
||||
ESteamNetworkingAvailability (*relay_network)(void *, RelayNetwork *){};
|
||||
void (*debug_output)(void *, ESteamNetworkingSocketsDebugOutputType, void (*)(ESteamNetworkingSocketsDebugOutputType, const char *)){};
|
||||
// The options of a connection by address: as options(), without the one about relays.
|
||||
std::array<SteamNetworkingConfigValue_t, 5> direct_options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 5> out{};
|
||||
out[4].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000);
|
||||
out[0] = callback_option();
|
||||
out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
||||
out[2].SetInt32(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
||||
out[3].SetInt32(k_ESteamNetworkingConfig_SendBufferSize, 4 * 1024 * 1024);
|
||||
return out;
|
||||
}
|
||||
// A guest's direct attempt at its host did not come up (no answer, refused, or not the
|
||||
// server it should be): the same link carries on through Steam's relays.
|
||||
void fall_back(std::uint64_t id, Link &link, const std::string &why) {
|
||||
if (link.handle && close) close(sockets, link.handle, 0, "Direct connection not used", false);
|
||||
SteamNetworkingIdentity identity{};
|
||||
identity.SetSteamID64(id);
|
||||
auto relayed = options();
|
||||
const auto connection = connect(sockets, &identity, virtual_port, static_cast<int>(relayed.size()), relayed.data());
|
||||
const auto name = std::move(link.name);
|
||||
link = Link{connection, GetTickCount64(), false, name, connection ? lanes.setup(sockets, connection) : false};
|
||||
state.detail = "Direct connection not available (" + why + "): connecting through Steam's relays...";
|
||||
direct_note = state.detail;
|
||||
note_direct("Direct connection to the server did not come up (" + why + "); using Steam's relays.");
|
||||
}
|
||||
std::string direct_note; // how the last direct attempt went
|
||||
std::vector<std::string> direct_notes; // take_direct_notes
|
||||
void note_direct(std::string text) {
|
||||
if (direct_notes.size() < 64) direct_notes.push_back(std::move(text));
|
||||
}
|
||||
// Gives one open connection the current send rate. A connection a listen socket
|
||||
// accepts starts with the rate the socket was opened with, so each one is given it.
|
||||
bool apply_rate(HSteamNetConnection handle) {
|
||||
if (!handle || !set_config || !utils) return false;
|
||||
bool ok = true;
|
||||
const auto set = [&](ESteamNetworkingConfigValue option, int32 value) {
|
||||
ok &= set_config(utils, option, k_ESteamNetworkingConfig_Connection, static_cast<intptr_t>(handle),
|
||||
k_ESteamNetworkingConfig_Int32, &value);
|
||||
};
|
||||
// The minimum never above the maximum on the way, whatever the rate was before.
|
||||
set(k_ESteamNetworkingConfig_SendRateMin, min_send_rate);
|
||||
set(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
||||
set(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
||||
return ok;
|
||||
}
|
||||
// "ping 48 ms, quality 99%/97%, 0 B queued": what was last measured of a link.
|
||||
static std::string measured(const Link &link) {
|
||||
if (!link.status_ok) return "link not measured";
|
||||
const auto &s = link.status;
|
||||
const auto percent = [](float quality) { return quality < 0 ? std::string("?") : std::to_string(static_cast<int>(quality * 100.f + .5f)) + "%"; };
|
||||
return "ping " + std::to_string(s.m_nPing) + " ms, quality " + percent(s.m_flConnectionQualityLocal) + " here / " +
|
||||
percent(s.m_flConnectionQualityRemote) + " there, " + std::to_string(s.m_cbPendingReliable + s.m_cbPendingUnreliable) +
|
||||
" B queued (" + std::to_string(queue_time(s) / 1000) + " ms), " +
|
||||
std::to_string(static_cast<int>(s.m_flOutBytesPerSec / 1024.f + .5f)) + " KB/s out, " +
|
||||
std::to_string(static_cast<int>(s.m_flInBytesPerSec / 1024.f + .5f)) + " KB/s in";
|
||||
}
|
||||
std::uint64_t frame{};
|
||||
ULONGLONG next_measure{}, next_sweep{};
|
||||
struct Name {
|
||||
@@ -108,6 +183,9 @@ struct SteamTransport::Impl {
|
||||
std::deque<SteamNetConnectionStatusChangedCallback_t> events;
|
||||
static inline std::atomic<Impl *> callback_owner{};
|
||||
HSteamListenSocket (*listen)(void *, int, int, const SteamNetworkingConfigValue_t *){};
|
||||
// By address instead of through the relays; absent from a Steam too old to have them.
|
||||
HSteamListenSocket (*listen_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){};
|
||||
HSteamNetConnection (*connect_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){};
|
||||
HSteamNetConnection (*connect)(void *, const SteamNetworkingIdentity *, int, int,
|
||||
const SteamNetworkingConfigValue_t *){};
|
||||
EResult (*accept)(void *, HSteamNetConnection){};
|
||||
@@ -146,12 +224,26 @@ struct SteamTransport::Impl {
|
||||
reinterpret_cast<void *>(&changed));
|
||||
return option;
|
||||
}
|
||||
std::array<SteamNetworkingConfigValue_t, 2> options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 2> out{};
|
||||
std::array<SteamNetworkingConfigValue_t, 6> options() {
|
||||
std::array<SteamNetworkingConfigValue_t, 6> out{};
|
||||
out[5].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000); // 5000: Steam's own
|
||||
out[0] = callback_option();
|
||||
// Raise this connection's ceiling; Steam retains congestion control and
|
||||
// its default minimum. Do not alter the game's global Steam settings.
|
||||
out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, 1024 * 1024);
|
||||
// This connection's send rate. Steam asks for the minimum and the maximum to be set
|
||||
// to the same value: with only the maximum raised a connection stays at the 256 KB/s
|
||||
// default, which a busy server fills for every player. Do not alter the game's
|
||||
// global Steam settings.
|
||||
listen_rate = send_rate;
|
||||
out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
||||
out[3].SetInt32(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
||||
// Room for what must arrive. Steam refuses a reliable message once 512 KB wait, its
|
||||
// default, and a busy connection already holds half of that in poses: the refusal
|
||||
// then ends the session for a player whose connection was only slow.
|
||||
out[4].SetInt32(k_ESteamNetworkingConfig_SendBufferSize, 4 * 1024 * 1024);
|
||||
// Every connection goes through Steam's relays, which is how players who are not
|
||||
// Steam friends already connect: no player's IP address is shared with the others
|
||||
// in a session, whatever each one's own Steam setting is.
|
||||
out[2].SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable,
|
||||
k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Disable);
|
||||
return out;
|
||||
}
|
||||
void read_status(Link &link) {
|
||||
@@ -230,7 +322,12 @@ struct SteamTransport::Impl {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static int send_flags(bool reliable, TrafficLane lane) {
|
||||
int packing_us{}; // set_packing: how long a message may wait for company, 0 for not at all
|
||||
int send_flags(bool reliable, TrafficLane lane) const {
|
||||
// Packing: without "no Nagle" Steam fills a packet with what follows within the time
|
||||
// above, and without "no delay" it queues instead of dropping (admit() has already
|
||||
// turned away what a slow connection cannot take).
|
||||
if (packing_us && lane != TrafficLane::voice) return reliable ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_Unreliable;
|
||||
return reliable ? (lane == TrafficLane::cosmetics ? k_nSteamNetworkingSend_Reliable
|
||||
: k_nSteamNetworkingSend_ReliableNoNagle)
|
||||
: k_nSteamNetworkingSend_UnreliableNoDelay;
|
||||
@@ -298,6 +395,29 @@ bool SteamTransport::bind(void *library, void *sockets, void *networking_utils)
|
||||
} catch (...) {
|
||||
p.lanes = {};
|
||||
}
|
||||
p.utils = networking_utils;
|
||||
try {
|
||||
p.listen_ip = symbol<decltype(p.listen_ip)>(module, "SteamAPI_ISteamNetworkingSockets_CreateListenSocketIP");
|
||||
p.connect_ip = symbol<decltype(p.connect_ip)>(module, "SteamAPI_ISteamNetworkingSockets_ConnectByIPAddress");
|
||||
} catch (...) {
|
||||
p.listen_ip = nullptr;
|
||||
p.connect_ip = nullptr;
|
||||
}
|
||||
try {
|
||||
p.debug_output = symbol<decltype(p.debug_output)>(module, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction");
|
||||
} catch (...) {
|
||||
p.debug_output = nullptr;
|
||||
}
|
||||
try {
|
||||
p.relay_network = symbol<decltype(p.relay_network)>(module, "SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus");
|
||||
} catch (...) {
|
||||
p.relay_network = nullptr;
|
||||
}
|
||||
try {
|
||||
p.set_config = symbol<decltype(p.set_config)>(module, "SteamAPI_ISteamNetworkingUtils_SetConfigValue");
|
||||
} catch (...) {
|
||||
p.set_config = nullptr; // open connections keep their rate; new ones get the new one
|
||||
}
|
||||
symbol<void (*)(void *)>(module,
|
||||
"SteamAPI_ISteamNetworkingUtils_InitRelayNetworkAccess")(networking_utils);
|
||||
symbol<ESteamNetworkingAvailability (*)(void *)>(
|
||||
@@ -402,6 +522,9 @@ void SteamTransport::disconnect(std::uint64_t id, const char *reason) {
|
||||
return;
|
||||
const auto handle = found->second.handle;
|
||||
p.links.erase(found);
|
||||
// Closed from this side, unless Steam's own account of the end is already kept.
|
||||
if (p.closed.size() >= 256) p.closed.clear();
|
||||
p.closed.try_emplace(id, std::string("closed by this side: ") + (reason ? reason : ""));
|
||||
if (handle && p.close)
|
||||
p.close(p.sockets, handle, 1000, reason, false);
|
||||
p.state.detail = reason;
|
||||
@@ -411,6 +534,8 @@ void SteamTransport::stop() {
|
||||
auto &p = *impl_;
|
||||
const auto listener = p.listener;
|
||||
p.listener = 0;
|
||||
if (p.direct_listener && p.close_listener) p.close_listener(p.sockets, p.direct_listener);
|
||||
p.direct_listener = 0;
|
||||
p.state.hosting = false;
|
||||
p.allowed.clear();
|
||||
p.host_id = 0;
|
||||
@@ -440,7 +565,7 @@ bool SteamTransport::host(unsigned capacity) {
|
||||
p.state.detail = p.listener ? "Waiting for players to join." : "Steam could not open the P2P listener.";
|
||||
return p.listener != 0;
|
||||
}
|
||||
bool SteamTransport::join(std::uint64_t id) {
|
||||
bool SteamTransport::join(std::uint64_t id, std::uint32_t direct_ip, std::uint16_t direct_port) {
|
||||
stop();
|
||||
if (!open())
|
||||
return false;
|
||||
@@ -456,10 +581,65 @@ bool SteamTransport::join(std::uint64_t id) {
|
||||
// Guests listen only for identities admitted in the host's reliable roster.
|
||||
// Failure to listen leaves the host-forwarded path usable.
|
||||
p.listener = p.listen(p.sockets, virtual_port, static_cast<int>(options.size()), options.data());
|
||||
// A dedicated server's own address first, when it has one (the header).
|
||||
if (direct_ip && direct_port && p.connect_ip) {
|
||||
SteamNetworkingIPAddr address{};
|
||||
address.Clear();
|
||||
address.SetIPv4(direct_ip, direct_port);
|
||||
auto by_address = p.direct_options();
|
||||
if (const auto connection = p.connect_ip(p.sockets, &address, static_cast<int>(by_address.size()), by_address.data())) {
|
||||
p.closed.erase(id);
|
||||
auto &link = p.links.emplace(id, Impl::Link{connection, GetTickCount64(), false, name(id),
|
||||
p.lanes.setup(p.sockets, connection)}).first->second;
|
||||
link.direct = true;
|
||||
p.note_direct("Connecting straight to the server at " + std::to_string(direct_ip >> 24) + "." + std::to_string((direct_ip >> 16) & 255) +
|
||||
"." + std::to_string((direct_ip >> 8) & 255) + "." + std::to_string(direct_ip & 255) + ":" + std::to_string(direct_port) + ".");
|
||||
p.direct_note = "Connecting straight to the server...";
|
||||
p.state.detail = p.direct_note;
|
||||
p.publish_links();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const bool connected = connect_peer(id);
|
||||
p.state.detail = connected ? "Connecting through Steam..." : "Steam rejected the connection request.";
|
||||
return connected;
|
||||
}
|
||||
namespace {
|
||||
// Steam calls this from its own threads.
|
||||
std::mutex steam_debug_mutex;
|
||||
std::vector<std::string> steam_debug_lines;
|
||||
void steam_debug_output(ESteamNetworkingSocketsDebugOutputType, const char *text) {
|
||||
std::lock_guard lock(steam_debug_mutex);
|
||||
if (text && steam_debug_lines.size() < 400) steam_debug_lines.emplace_back(text);
|
||||
}
|
||||
} // namespace
|
||||
bool SteamTransport::set_steam_debug(bool on) {
|
||||
auto &p = *impl_;
|
||||
if (!p.debug_output || !p.utils) return false;
|
||||
p.debug_output(p.utils, on ? k_ESteamNetworkingSocketsDebugOutputType_Verbose : k_ESteamNetworkingSocketsDebugOutputType_None,
|
||||
on ? &steam_debug_output : nullptr);
|
||||
return true;
|
||||
}
|
||||
std::vector<std::string> SteamTransport::take_direct_notes() {
|
||||
auto notes = std::exchange(impl_->direct_notes, {});
|
||||
std::lock_guard lock(steam_debug_mutex);
|
||||
for (auto &line : steam_debug_lines) notes.push_back("Steam: " + std::move(line));
|
||||
steam_debug_lines.clear();
|
||||
return notes;
|
||||
}
|
||||
void SteamTransport::set_packing(unsigned milliseconds) { impl_->packing_us = static_cast<int>(std::min(milliseconds, 50U) * 1000); }
|
||||
bool SteamTransport::listen_direct(std::uint16_t port) {
|
||||
auto &p = *impl_;
|
||||
if (!p.state.hosting || !port || !p.listen_ip) return false;
|
||||
if (p.direct_listener) return true;
|
||||
SteamNetworkingIPAddr address{};
|
||||
address.Clear(); // every address of this machine
|
||||
address.m_port = port;
|
||||
auto options = p.direct_options();
|
||||
p.listen_rate = p.send_rate;
|
||||
p.direct_listener = p.listen_ip(p.sockets, &address, static_cast<int>(options.size()), options.data());
|
||||
return p.direct_listener != 0;
|
||||
}
|
||||
bool SteamTransport::connect_peer(std::uint64_t id) {
|
||||
auto &p = *impl_;
|
||||
if (!p.state.ready || p.state.hosting || !p.allowed.contains(id) || id == p.state.local_id)
|
||||
@@ -471,6 +651,7 @@ bool SteamTransport::connect_peer(std::uint64_t id) {
|
||||
auto options = p.options();
|
||||
const auto connection =
|
||||
p.connect(p.sockets, &identity, virtual_port, static_cast<int>(options.size()), options.data());
|
||||
p.closed.erase(id);
|
||||
if (connection)
|
||||
p.links.emplace(id, Impl::Link{connection, GetTickCount64(), false, name(id),
|
||||
p.lanes.setup(p.sockets, connection)});
|
||||
@@ -523,7 +704,10 @@ void SteamTransport::poll() {
|
||||
const auto existing = p.links.find(id);
|
||||
if (existing != p.links.end() && existing->second.handle == event.m_hConn)
|
||||
continue;
|
||||
if (event.m_info.m_hListenSocket != p.listener || !p.listener || !id || id == p.state.local_id ||
|
||||
const bool direct = p.direct_listener && event.m_info.m_hListenSocket == p.direct_listener;
|
||||
if (direct) p.note_direct("Direct connection asked for by " + std::to_string(id) + ".");
|
||||
// (A connection by address names a Steam ID only when Steam has vouched for it.)
|
||||
if ((event.m_info.m_hListenSocket != p.listener && !direct) || !p.listener || !id || id == p.state.local_id ||
|
||||
(!p.state.hosting && !p.allowed.contains(id)) || p.links.size() >= p.capacity - 1 ||
|
||||
existing != p.links.end() ||
|
||||
event.m_info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID) {
|
||||
@@ -534,8 +718,10 @@ void SteamTransport::poll() {
|
||||
p.close(p.sockets, event.m_hConn, 4003, "Cannot accept connection", false);
|
||||
continue;
|
||||
}
|
||||
p.closed.erase(id);
|
||||
p.links.emplace(id, Impl::Link{event.m_hConn, now, false, name(id),
|
||||
p.lanes.setup(p.sockets, event.m_hConn)});
|
||||
p.lanes.setup(p.sockets, event.m_hConn)}).first->second.direct = direct;
|
||||
if (p.send_rate != p.listen_rate) p.apply_rate(event.m_hConn);
|
||||
}
|
||||
// Connected links report changes through the status callback (above), so read their
|
||||
// state only then, plus once a second in case a callback was dropped.
|
||||
@@ -548,18 +734,53 @@ void SteamTransport::poll() {
|
||||
if (link.connected && !link.recheck && !sweep)
|
||||
continue;
|
||||
link.recheck = false;
|
||||
// A guest's direct attempt gets a few seconds; the relays take that long themselves.
|
||||
const bool trying_direct = link.direct && !link.connected && !p.state.hosting;
|
||||
if (trying_direct && now - link.connecting_since > 5000) {
|
||||
p.fall_back(id, link, "no answer");
|
||||
continue;
|
||||
}
|
||||
SteamNetConnectionInfo_t info{};
|
||||
if (!p.get_info(p.sockets, link.handle, &info)) {
|
||||
if (trying_direct) {
|
||||
p.fall_back(id, link, "it went away");
|
||||
continue;
|
||||
}
|
||||
disconnect(id, "Steam connection disappeared.");
|
||||
continue;
|
||||
}
|
||||
if (trying_direct && info.m_eState == k_ESteamNetworkingConnectionState_Connected &&
|
||||
(info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID || info.m_identityRemote.GetSteamID64() != id)) {
|
||||
// Something else answers at that address: not the server that was asked for.
|
||||
p.fall_back(id, link, "another server answered there");
|
||||
continue;
|
||||
}
|
||||
// (A server that answers and closes the connection itself, 1000 to 1999, has given its
|
||||
// answer: full, banned, wrong password. That is not a route to try another way.)
|
||||
if (trying_direct && !(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer && info.m_eEndReason >= 1000 && info.m_eEndReason < 2000) &&
|
||||
(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ||
|
||||
info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally)) {
|
||||
p.fall_back(id, link, "Steam reason " + std::to_string(info.m_eEndReason));
|
||||
continue;
|
||||
}
|
||||
if (info.m_eState == k_ESteamNetworkingConnectionState_Connected) {
|
||||
if (trying_direct) {
|
||||
p.direct_note = "Connected straight to the server.";
|
||||
p.note_direct(p.direct_note);
|
||||
}
|
||||
if (!link.connected && !link.prioritized) link.prioritized = p.lanes.setup(p.sockets, link.handle);
|
||||
link.connected = true;
|
||||
p.state.detail = "Steam connected.";
|
||||
} else if (info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ||
|
||||
info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally) {
|
||||
const auto detail = bounded(info.m_szEndDebug, sizeof(info.m_szEndDebug));
|
||||
// Steam's end reasons: 1xxx the application closed it, 3xxx a problem at this end,
|
||||
// 4xxx one at the other end (4001: it stopped answering), 5xxx anything else.
|
||||
if (p.closed.size() >= 256) p.closed.clear();
|
||||
p.closed[id] = std::string(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ? "closed by their game"
|
||||
: "connection lost") +
|
||||
", Steam reason " + std::to_string(info.m_eEndReason) + (detail.empty() ? std::string{} : " \"" + detail + "\"") +
|
||||
"; last " + Impl::measured(link);
|
||||
disconnect(id, detail.empty() ? "Steam connection closed." : detail.c_str());
|
||||
} else if (now - link.connecting_since > 20000)
|
||||
disconnect(id, "Steam connection timed out.");
|
||||
@@ -570,6 +791,87 @@ void SteamTransport::poll() {
|
||||
}
|
||||
p.publish_links();
|
||||
}
|
||||
std::string SteamTransport::take_closed(std::uint64_t id) {
|
||||
auto &p = *impl_;
|
||||
const auto found = p.closed.find(id);
|
||||
if (found == p.closed.end()) return {};
|
||||
auto text = std::move(found->second);
|
||||
p.closed.erase(found);
|
||||
return text;
|
||||
}
|
||||
int SteamTransport::send_rate() const { return impl_->send_rate; }
|
||||
bool SteamTransport::set_send_rate(int rate) {
|
||||
auto &p = *impl_;
|
||||
if (rate < min_send_rate || rate > max_send_rate) return false;
|
||||
p.send_rate = rate;
|
||||
bool all = true;
|
||||
for (auto &[id, link] : p.links)
|
||||
if (link.handle) all &= p.apply_rate(link.handle);
|
||||
return all;
|
||||
}
|
||||
std::string SteamTransport::relay_status() const {
|
||||
const auto &p = *impl_;
|
||||
if (!p.relay_network || !p.utils) return "unknown (this Steam does not say)";
|
||||
Impl::RelayNetwork status;
|
||||
const auto available = p.relay_network(p.utils, &status);
|
||||
status.message[sizeof(status.message) - 1] = 0;
|
||||
std::string text = available == k_ESteamNetworkingAvailability_Current ? "OK"
|
||||
: static_cast<int>(available) < 0 ? "FAILED"
|
||||
: "not ready";
|
||||
if (status.measuring) text += ", measuring pings to the relays";
|
||||
if (status.message[0] && std::string_view(status.message) != "OK") text += std::string(": ") + status.message;
|
||||
return text;
|
||||
}
|
||||
std::int64_t SteamTransport::pending(std::uint64_t id) const {
|
||||
const auto found = impl_->links.find(id);
|
||||
if (found == impl_->links.end() || !found->second.status_ok) return 0;
|
||||
return std::int64_t{found->second.status.m_cbPendingReliable} + found->second.status.m_cbPendingUnreliable;
|
||||
}
|
||||
std::string SteamTransport::link_report(std::uint64_t id) {
|
||||
auto &p = *impl_;
|
||||
const auto found = p.links.find(id);
|
||||
if (found == p.links.end()) return "no connection";
|
||||
if (found->second.handle && found->second.connected) p.read_status(found->second);
|
||||
return Impl::measured(found->second);
|
||||
}
|
||||
std::vector<TransportLink> SteamTransport::links() {
|
||||
auto &p = *impl_;
|
||||
std::vector<TransportLink> out;
|
||||
out.reserve(p.links.size());
|
||||
for (auto &[id, link] : p.links) {
|
||||
TransportLink row;
|
||||
row.id = id;
|
||||
row.connected = link.connected;
|
||||
if (link.handle && link.connected) p.read_status(link);
|
||||
if (link.connected && link.status_ok) {
|
||||
const auto &s = link.status;
|
||||
row.measured = true;
|
||||
row.ping_ms = s.m_nPing;
|
||||
row.pending_bytes = s.m_cbPendingReliable + s.m_cbPendingUnreliable;
|
||||
row.send_rate = s.m_nSendRateBytesPerSecond;
|
||||
row.quality_local = s.m_flConnectionQualityLocal;
|
||||
row.quality_remote = s.m_flConnectionQualityRemote;
|
||||
row.out_bps = s.m_flOutBytesPerSec;
|
||||
row.in_bps = s.m_flInBytesPerSec;
|
||||
row.queue_us = queue_time(s);
|
||||
row.direct = link.direct;
|
||||
SteamNetConnectionInfo_t info{};
|
||||
if (p.get_info && p.get_info(p.sockets, link.handle, &info)) {
|
||||
// A location's ID is its name's characters packed into a number.
|
||||
const auto named = [](SteamNetworkingPOPID id) {
|
||||
std::string text;
|
||||
for (const unsigned shift : {16U, 8U, 0U, 24U})
|
||||
if (const auto c = static_cast<char>((id >> shift) & 0xff); c > 32 && c < 127) text.push_back(c);
|
||||
return text;
|
||||
};
|
||||
row.relay = named(info.m_idPOPRelay);
|
||||
row.remote_relay = named(info.m_idPOPRemote);
|
||||
}
|
||||
}
|
||||
out.push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string SteamTransport::name(std::uint64_t id) {
|
||||
auto &p = *impl_;
|
||||
if (!p.friends || !id)
|
||||
@@ -608,7 +910,7 @@ bool SteamTransport::send(std::uint64_t id, std::span<const std::uint8_t> bytes,
|
||||
const auto found = p.links.find(id);
|
||||
if (found == p.links.end() || !p.admit(found->second, bytes, fresh, lane))
|
||||
return false;
|
||||
const int flags = Impl::send_flags(reliable, lane);
|
||||
const int flags = p.send_flags(reliable, lane);
|
||||
const auto result = found->second.prioritized
|
||||
? p.lanes.transmit(p.sockets, found->second.handle, bytes, flags, lane)
|
||||
: p.send_message(p.sockets, found->second.handle, bytes.data(), static_cast<uint32>(bytes.size()), flags, nullptr);
|
||||
@@ -626,7 +928,7 @@ void SteamTransport::send_batch(std::span<TransportSend> messages) {
|
||||
const auto found = p.links.find(m.id);
|
||||
if (found == p.links.end() || !p.admit(found->second, m.bytes, m.fresh, m.lane))
|
||||
continue;
|
||||
const int flags = Impl::send_flags(m.reliable, m.lane);
|
||||
const int flags = p.send_flags(m.reliable, m.lane);
|
||||
const auto &link = found->second;
|
||||
if (!link.prioritized || m.bytes.empty()) {
|
||||
// Without lanes (older Steam adapters) each message goes out as send() sends it.
|
||||
@@ -669,32 +971,41 @@ std::vector<TransportMessage> SteamTransport::receive() {
|
||||
const auto &link = (it++)->second;
|
||||
if (!link.connected)
|
||||
continue;
|
||||
SteamNetworkingMessage_t *messages[128]{};
|
||||
const int count = p.receive_messages(p.sockets, link.handle, messages, 128);
|
||||
if (count < 0) {
|
||||
disconnect(id, "Steam receive failed.");
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto *message = messages[i];
|
||||
if (!message)
|
||||
continue;
|
||||
struct Release {
|
||||
Impl &p;
|
||||
SteamNetworkingMessage_t *m;
|
||||
~Release() { p.release_message(m); }
|
||||
} release{p, message};
|
||||
if (message->m_cbSize <= 0 || message->m_cbSize > static_cast<int>(max_packet) ||
|
||||
!message->m_pData) {
|
||||
++p.state.dropped;
|
||||
++p.state.invalid_messages;
|
||||
continue;
|
||||
// Everything that has arrived, not one batch a call: a dedicated server sends a busy
|
||||
// session's player a few thousand messages a second, and what a slow frame leaves
|
||||
// behind would wait in Steam, later and later, taking memory until it is read.
|
||||
bool failed{};
|
||||
for (unsigned batch = 0; batch < 64 && !failed; ++batch) {
|
||||
SteamNetworkingMessage_t *messages[128]{};
|
||||
const int count = p.receive_messages(p.sockets, link.handle, messages, 128);
|
||||
if (count < 0) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
const auto *data = static_cast<const std::uint8_t *>(message->m_pData);
|
||||
result.push_back({id, {data, data + message->m_cbSize}});
|
||||
++p.state.received;
|
||||
p.state.received_bytes += static_cast<std::uint64_t>(message->m_cbSize);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto *message = messages[i];
|
||||
if (!message)
|
||||
continue;
|
||||
struct Release {
|
||||
Impl &p;
|
||||
SteamNetworkingMessage_t *m;
|
||||
~Release() { p.release_message(m); }
|
||||
} release{p, message};
|
||||
if (message->m_cbSize <= 0 || message->m_cbSize > static_cast<int>(max_packet) ||
|
||||
!message->m_pData) {
|
||||
++p.state.dropped;
|
||||
++p.state.invalid_messages;
|
||||
continue;
|
||||
}
|
||||
const auto *data = static_cast<const std::uint8_t *>(message->m_pData);
|
||||
result.push_back({id, {data, data + message->m_cbSize},
|
||||
message->m_usecTimeReceived > 0 ? static_cast<std::uint64_t>(message->m_usecTimeReceived) : 0});
|
||||
++p.state.received;
|
||||
p.state.received_bytes += static_cast<std::uint64_t>(message->m_cbSize);
|
||||
}
|
||||
if (count < 128) break;
|
||||
}
|
||||
if (failed) disconnect(id, "Steam receive failed.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,27 @@ struct TransportPeer {
|
||||
std::uint64_t id{};
|
||||
bool connected{};
|
||||
};
|
||||
// What Steam measures of one connection right now (SteamTransport::links).
|
||||
struct TransportLink {
|
||||
std::uint64_t id{};
|
||||
bool connected{}, measured{};
|
||||
int ping_ms{}, pending_bytes{}, send_rate{}; // send_rate: bytes a second Steam will let out to them
|
||||
float quality_local = -1, quality_remote = -1; // 0-1 of packets delivered each way; -1 unknown
|
||||
float out_bps{}, in_bps{};
|
||||
std::uint64_t queue_us{}; // how long a message sent now would wait
|
||||
// Which of Steam's relay locations the connection goes through, by their short names
|
||||
// ("ord", "fra"...): the one this side uses and the one the other side uses. A ping far
|
||||
// above the direct one is the route, and these say which way it went.
|
||||
std::string relay, remote_relay;
|
||||
bool direct{}; // not through the relays at all
|
||||
};
|
||||
struct TransportMessage {
|
||||
std::uint64_t peer{};
|
||||
std::vector<std::uint8_t> bytes;
|
||||
// When Steam received it, in microseconds on a clock of its own (only differences mean
|
||||
// anything); 0 when the transport does not say. A receiver that was kept from reading for a
|
||||
// while gets everything that arrived meanwhile in one go: this is what tells that from a flood.
|
||||
std::uint64_t arrived{};
|
||||
};
|
||||
// One message of SteamTransport::send_batch, with send()'s arguments; `sent` receives what
|
||||
// send() would have returned for it.
|
||||
@@ -40,6 +58,8 @@ struct TransportStatus {
|
||||
std::uint64_t queue_us{}, skipped{}, send_failures{}, invalid_messages{}, sent_bytes{}, received_bytes{},
|
||||
raw_sent_bytes{};
|
||||
};
|
||||
// What each connection may send a second (bytes): a crowded server's players, mostly.
|
||||
inline constexpr int connection_send_rate = 900 * 1024, min_send_rate = 128 * 1024, max_send_rate = 16 * 1024 * 1024;
|
||||
class SteamTransport {
|
||||
public:
|
||||
SteamTransport();
|
||||
@@ -51,7 +71,26 @@ class SteamTransport {
|
||||
// steam_api64.dll module handle it was started with.
|
||||
bool open_game_server(void *steam_api);
|
||||
bool host(unsigned capacity);
|
||||
bool join(std::uint64_t steam_id);
|
||||
// `direct_ip` and `direct_port` (host byte order; 0 for none): the address a dedicated server
|
||||
// listens on for direct connections. It is tried first, and the connection goes through
|
||||
// Steam's relays as usual if it has not come up in a few seconds. Either way Steam vouches
|
||||
// for who is at the other end, and only a server with this Steam ID is taken.
|
||||
bool join(std::uint64_t steam_id, std::uint32_t direct_ip = 0, std::uint16_t direct_port = 0);
|
||||
// A host that also takes connections straight to this UDP port, without Steam's relays.
|
||||
// For a dedicated server with a public address. False when Steam could not open it.
|
||||
bool listen_direct(std::uint16_t port);
|
||||
// Lets Steam hold a message back for up to this many milliseconds to send it in the same
|
||||
// packet as the next ones (0: every message is its own packet, at once, as a game sends
|
||||
// them). A dedicated server sends each player hundreds of small messages a second, and
|
||||
// every packet carries the same headers again. Voice is never held. Set before host().
|
||||
void set_packing(unsigned milliseconds);
|
||||
// What happened to direct connections since this was last asked, a line each, for a log:
|
||||
// a guest's attempt at its host (where to, and how it went), or a host's incoming ones.
|
||||
std::vector<std::string> take_direct_notes();
|
||||
// Has Steam's networking say what it is doing (connections asked for, refused, packets it
|
||||
// would not take and why), as lines for take_direct_notes. For finding out why a
|
||||
// connection does not come up; a lot of text on a busy server. False when Steam cannot.
|
||||
bool set_steam_debug(bool on);
|
||||
bool connect_peer(std::uint64_t steam_id);
|
||||
void allow_peers(std::span<const Member>);
|
||||
bool socket_test();
|
||||
@@ -65,6 +104,23 @@ class SteamTransport {
|
||||
std::vector<TransportMessage> receive();
|
||||
std::string name(std::uint64_t id);
|
||||
const TransportStatus &status() const;
|
||||
// For a host's log. How Steam says a connection it closed ended, once (empty when this
|
||||
// side closed it, or it is still open): who closed it, Steam's reason code and words, and
|
||||
// the last ping and quality measured. And a connected player's link right now.
|
||||
std::string take_closed(std::uint64_t id);
|
||||
std::string link_report(std::uint64_t id);
|
||||
// Bytes waiting to go out to a player, as last measured (a few times a second).
|
||||
std::int64_t pending(std::uint64_t id) const;
|
||||
// What Steam says of its relay network from here, in its own words ("OK", or what it is
|
||||
// waiting for or failed at). Every connection goes through the relays: while this is not
|
||||
// OK, players time out joining ("negotiate rendezvous") or come by a long way round.
|
||||
std::string relay_status() const;
|
||||
// What each connection may send a second from now on, the open ones included. False when
|
||||
// out of range, or when an open connection could not be changed (new ones still get it).
|
||||
bool set_send_rate(int bytes_per_second);
|
||||
int send_rate() const;
|
||||
// Every connection, measured now: one Steam call each, so for a command, not every tick.
|
||||
std::vector<TransportLink> links();
|
||||
|
||||
private:
|
||||
bool bind(void *steam_api, void *sockets, void *networking_utils);
|
||||
|
||||
@@ -130,11 +130,112 @@ void audio_checks() {
|
||||
check(raw.size() < packet_header_size + 2 + p.audio.size() * 383, "Sparse audio failed to shrink fixture");
|
||||
std::cout << "Audio: continuous coalescing, contact/selector edges, exact values, idle heartbeat, reordering, deduplication and disconnect passed.\n";
|
||||
}
|
||||
void coarse_checks() {
|
||||
// Coarser rotations still encode and decode as any pose, land within a step of the
|
||||
// original, and make a bone that barely turned the same bytes as before.
|
||||
std::mt19937 random(11);
|
||||
std::uniform_real_distribution<float> any(-1.f, 1.f);
|
||||
auto p = fixture();
|
||||
p.kind = PacketKind::pose;
|
||||
for (auto &t : p.pose.skater) {
|
||||
t.rotation = {any(random), any(random), any(random), any(random)};
|
||||
float norm{};
|
||||
for (float v : t.rotation) norm += v * v;
|
||||
for (float &v : t.rotation) v /= std::sqrt(norm);
|
||||
}
|
||||
// The awkward ones: two components equal and as large as a smaller one can be, and an exact axis.
|
||||
p.pose.skater[0].rotation = {.70710678f, .70710678f, 0, 0};
|
||||
p.pose.skater[1].rotation = {0, 0, 0, -1};
|
||||
p.pose.skater[2].rotation = {.5f, .5f, .5f, .5f};
|
||||
for (const unsigned bits : {4U, 6U, 7U, 11U}) {
|
||||
auto coarse = p;
|
||||
coarsen_rotations(coarse.pose, bits);
|
||||
const auto decoded = decode(encode(coarse, true));
|
||||
check(decoded && decoded->pose.skater.size() == p.pose.skater.size(), "A coarsened pose did not decode");
|
||||
const float step = static_cast<float>(1U << bits) / 46339.5358f;
|
||||
for (std::size_t i = 0; decoded && i < p.pose.skater.size(); ++i) {
|
||||
const auto &a = p.pose.skater[i].rotation, &b = decoded->pose.skater[i].rotation;
|
||||
const float same = std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]);
|
||||
// Each of three components is off by at most half a step.
|
||||
check(same > 1.f - 2.f * step * step - 1e-4f, "A coarsened rotation moved by more than its step");
|
||||
}
|
||||
// A turn of a fortieth of a step is lost: the same bytes, so a difference leaves the bone out.
|
||||
auto nudged = p;
|
||||
for (auto &t : nudged.pose.skater) {
|
||||
t.rotation[0] += step * .025f;
|
||||
float norm{};
|
||||
for (float v : t.rotation) norm += v * v;
|
||||
for (float &v : t.rotation) v /= std::sqrt(norm);
|
||||
}
|
||||
coarsen_rotations(nudged.pose, bits);
|
||||
const auto first = encode(coarse, true), second = encode(nudged, true);
|
||||
std::size_t different{};
|
||||
for (std::size_t i = 0; i < std::min(first.size(), second.size()); ++i) different += first[i] != second[i];
|
||||
check(first.size() == second.size() && different < first.size() / 20, "A turn far below the step still changed the pose's bytes");
|
||||
}
|
||||
// A resized bone is held to the limit both ways, and at 1 is not resized at all.
|
||||
auto big = fixture();
|
||||
big.pose.skater[5].scale = {4, 4, 4};
|
||||
big.pose.skater[6].scale = {.1f, 1, 1};
|
||||
auto held = big.pose, plain = big.pose, free = big.pose;
|
||||
limit_bone_scale(held, 1.5f);
|
||||
limit_bone_scale(plain, 1.f);
|
||||
limit_bone_scale(free, 0.f);
|
||||
check(held.skater[5].scale == std::array<float, 3>{1.5f, 1.5f, 1.5f} && std::abs(held.skater[6].scale[0] - 1.f / 1.5f) < 1e-5f &&
|
||||
plain.skater[5].scale == std::array<float, 3>{1, 1, 1} && plain.skater[6].scale == std::array<float, 3>{1, 1, 1} &&
|
||||
free.skater[5].scale == std::array<float, 3>{4, 4, 4},
|
||||
"A bone's scale was not held to the limit");
|
||||
std::cout << "Coarse rotations: valid poses, within a step, small turns unchanged.\n";
|
||||
}
|
||||
void rate_checks() {
|
||||
check(pose_interval(61*61, 50000) == 100000 && pose_interval(56*56, 100000) == 100000 &&
|
||||
pose_interval(49*49, 100000) == 50000, "Near-rate hysteresis failed");
|
||||
check(pose_interval(171*171, 100000) == 200000 && pose_interval(160*160, 200000) == 200000 &&
|
||||
pose_interval(149*149, 200000) == 100000, "Far-rate hysteresis failed");
|
||||
{
|
||||
// A crowd: no limit while everyone fits at the full rate, then the nearest at full,
|
||||
// the next at half, the rest at low, within the budget.
|
||||
std::vector<float> few(20), crowd(49), packed(127);
|
||||
for (std::size_t i = 0; i < packed.size(); ++i) {
|
||||
const auto d = static_cast<float>((packed.size() - i) * (packed.size() - i)); // farthest first
|
||||
packed[i] = d;
|
||||
if (i < crowd.size()) crowd[i] = d;
|
||||
if (i < few.size()) few[i] = d;
|
||||
}
|
||||
const auto none = crowd_limits(few, 30);
|
||||
// The default leaves twenty players in one place at the full rate, each to each.
|
||||
std::vector<float> twenty(crowd.begin(), crowd.begin() + 19);
|
||||
check(crowd_limits(twenty, 30).half == none.half && dingosdk::valid_crowd_budget(0) &&
|
||||
dingosdk::valid_crowd_budget(dingosdk::crowd_pose_budget) && !dingosdk::valid_crowd_budget(50),
|
||||
"The default crowd budget slowed twenty players, or a bad one was valid");
|
||||
check(crowd_interval(33333, 1e9f, none) == 33333, "A small group was limited");
|
||||
const auto count = [](std::span<const float> all, const CrowdLimits &limits, unsigned tps) {
|
||||
unsigned sent{};
|
||||
for (const auto d : all) sent += 1000000U / crowd_interval(dingosdk::multiplayer_pose_interval(tps), d, limits);
|
||||
return sent;
|
||||
};
|
||||
for (const unsigned tps : {20U, 30U, 60U, 120U}) {
|
||||
const auto limits = crowd_limits(crowd, tps, 600);
|
||||
const auto sent = count(crowd, limits, tps);
|
||||
// Within the budget, or at the least every crowd is sent: the nearest few at the
|
||||
// full and half rates, which at a high TPS is more than the budget by itself.
|
||||
const unsigned least = crowd_always_full * (1000000U / dingosdk::multiplayer_pose_interval(tps)) + crowd_always_half * 10 +
|
||||
(static_cast<unsigned>(crowd.size()) - crowd_always_full - crowd_always_half) * 5;
|
||||
check(sent <= std::max(602U, least + 2) && sent > 450, "A crowd was not sent within the budget");
|
||||
check(crowd_interval(dingosdk::multiplayer_pose_interval(tps), crowd.front(), limits) == dingosdk::multiplayer_pose_interval(tps) &&
|
||||
crowd_interval(dingosdk::multiplayer_pose_interval(tps), crowd.back(), limits) == 200000,
|
||||
"A crowd's nearest and farthest were not sent at the full and low rates");
|
||||
check(limits.half < limits.low, "A crowd had no half-rate ring");
|
||||
}
|
||||
// Too many for the budget even at the low rate: the nearest few are still sent at the
|
||||
// full and half rates, and only the rest at the low rate.
|
||||
const auto floor = crowd_limits(packed, 30, 600);
|
||||
check(count(packed, floor, 30) == crowd_always_full * 30 + crowd_always_half * 10 + (packed.size() - crowd_always_full - crowd_always_half) * 5,
|
||||
"An over-full crowd did not keep its nearest at the full and half rates");
|
||||
// Distance still slows what the crowd limit would send faster.
|
||||
check(crowd_interval(200000, 0, none) == 200000 && crowd_interval(100000, 0, crowd_limits(crowd, 30, 600)) == 100000,
|
||||
"A crowd limit sped a far player up");
|
||||
}
|
||||
for (auto interval : {8333U, 16666U, 33333U, 50000U, 100000U, 200000U}) {
|
||||
PoseBuffer buffer;
|
||||
auto p = fixture(); p.pose_interval_us = interval;
|
||||
@@ -175,6 +276,6 @@ void block_checks() {
|
||||
}
|
||||
}
|
||||
int main() {
|
||||
try { pose_checks(); audio_checks(); rate_checks(); block_checks(); }
|
||||
try { pose_checks(); audio_checks(); coarse_checks(); rate_checks(); block_checks(); }
|
||||
catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
#include "Extension/Customization/developer_board_material.h"
|
||||
#include "Extension/Customization/developer_hoodie_material.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
using namespace dingosdk::multiplayer;
|
||||
namespace {
|
||||
void check(bool ok, const std::string &message) {
|
||||
if (ok) return;
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
bool rejected(std::string_view json) {
|
||||
try {
|
||||
parse_identity_lists(json);
|
||||
} catch (const std::exception &) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
constexpr std::uint64_t dev = 76561198000000001ULL, other_dev = 76561198000000002ULL, homie = 76561198000000003ULL,
|
||||
creator = 76561198000000004ULL, stranger = 76561198000000005ULL;
|
||||
using L = IdentityList;
|
||||
|
||||
void parsing() {
|
||||
// The answer as the backend sends it; a list comes back sorted, each player once.
|
||||
const auto lists = parse_identity_lists(
|
||||
R"({"categories":{"dev":["76561198000000002","76561198000000001","76561198000000002"],)"
|
||||
R"("homie":["76561198000000003"],"content_creator":["76561198000000004"]}})");
|
||||
check(lists[0] == std::vector{dev, other_dev} && lists[1] == std::vector{homie} && lists[2] == std::vector{creator} &&
|
||||
lists[3].empty() && lists[4].empty(),
|
||||
"The backend's answer was not read as its three lists, with nobody banned");
|
||||
// The ban list comes beside the categories; a player can be in both.
|
||||
const auto bans = parse_identity_lists(
|
||||
R"({"categories":{"dev":["76561198000000001"]},"banned":["76561198000000005","76561198000000001","76561198000000005"]})");
|
||||
check(bans[0] == std::vector{dev} && bans[static_cast<std::size_t>(L::banned)] == std::vector{dev, stranger}, "The ban list was not read");
|
||||
// A newer backend may list more categories and fields, and an older one fewer.
|
||||
const auto partial = parse_identity_lists(
|
||||
R"({"updated":"2026-10-04","categories":{"moderator":["76561198000000005"],"dev":["76561198000000001"]}})");
|
||||
check(partial[0] == std::vector{dev} && partial[1].empty() && partial[2].empty(),
|
||||
"An unknown category was read, or a missing one was not empty");
|
||||
// The team's own servers come beside them too: servers signed in with a login token, whose
|
||||
// Steam ID stays. Nobody is on it when the answer leaves it out.
|
||||
const auto servers = parse_identity_lists(
|
||||
R"({"categories":{},"official_servers":["85568392924040002","85568392924040001","85568392924040002"]})");
|
||||
check(servers[static_cast<std::size_t>(L::official_server)] == std::vector<std::uint64_t>{85568392924040001ULL, 85568392924040002ULL} &&
|
||||
bans[static_cast<std::size_t>(L::official_server)].empty(),
|
||||
"The official server list was not read");
|
||||
for (const char *wrong : {R"({"categories":{},"official_servers":["76561198000000001"]})", // a player
|
||||
R"({"categories":{},"official_servers":["90071992551410001"]})", // an anonymous server
|
||||
R"({"categories":{},"official_servers":["85568392920039424"]})", // account 0
|
||||
R"({"categories":{"dev":["85568392924040001"]}})"}) // a server as a player
|
||||
check(rejected(wrong), "A list with the wrong kind of Steam ID was accepted: " + std::string(wrong));
|
||||
// Blocked servers: any server's ID, an anonymous one too; and the rule about login tokens.
|
||||
const auto blocks = parse_identity_lists(R"({"categories":{},"blocked_servers":["90071992551410001","85568392924040001"]})");
|
||||
check(blocks[static_cast<std::size_t>(L::blocked_server)] == std::vector<std::uint64_t>{85568392924040001ULL, 90071992551410001ULL} &&
|
||||
blocks[static_cast<std::size_t>(L::official_server)].empty() && servers[static_cast<std::size_t>(L::blocked_server)].empty(),
|
||||
"The blocked server list was not read");
|
||||
check(rejected(R"({"categories":{},"blocked_servers":["76561198000000001"]})"), "A player was accepted as a blocked server");
|
||||
check(parse_server_tokens_required(R"({"categories":{},"server_tokens_required":true})") &&
|
||||
!parse_server_tokens_required(R"({"categories":{},"server_tokens_required":false})") &&
|
||||
!parse_server_tokens_required(R"({"categories":{}})") && !parse_server_tokens_required(R"({"server_tokens_required":"yes"})"),
|
||||
"The login token rule was not read");
|
||||
// The ends of the range players' SteamID64s come from.
|
||||
check(!rejected(R"({"categories":{"dev":["76561197960265729","76561202255233023"]}})"), "A valid SteamID64 was refused");
|
||||
|
||||
for (const std::string_view wrong : {
|
||||
"", "go away\n", "<html>Just a moment...</html>", "[]", "{}", R"({"categories":[]})",
|
||||
R"({"categories":{"dev":"76561198000000001"}})", // not a list
|
||||
R"({"categories":{"dev":[76561198000000001]}})", // a number, which JSON readers round
|
||||
R"({"categories":{"dev":["76561198000000001 "]}})", R"({"categories":{"dev":["+76561198000000001"]}})",
|
||||
R"({"categories":{"dev":["zee_x64"]}})", R"({"categories":{"dev":[""]}})",
|
||||
R"({"categories":{"dev":["76561197960265728"]}})", // account 0
|
||||
R"({"categories":{"dev":["76561202255233024"]}})", // past the last account
|
||||
R"({"categories":{"dev":["103582791429521408"]}})", // a Steam group
|
||||
R"({"categories":{"homie":["1"]}})", R"({"categories":{},"banned":"76561198000000005"})",
|
||||
R"({"categories":{},"banned":[76561198000000005]})", R"({"categories":{},"banned":["everyone"]})"})
|
||||
check(rejected(wrong), "An answer that is not the lists was accepted: " + std::string(wrong));
|
||||
}
|
||||
|
||||
void lookup() {
|
||||
check(!reskate_developer(dev) && !identity_listed(homie, L::homie), "Someone was listed before any lists arrived");
|
||||
check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The first lists did not count as a change");
|
||||
check(reskate_developer(dev) && reskate_developer(other_dev) && identity_listed(homie, L::homie) &&
|
||||
identity_listed(creator, L::content_creator),
|
||||
"A listed player was not found");
|
||||
check(!reskate_developer(homie) && !reskate_developer(creator) && !reskate_developer(stranger) &&
|
||||
!identity_listed(dev, L::homie) && !identity_listed(homie, L::content_creator),
|
||||
"A player was found in a list they are not in");
|
||||
check(!reskate_developer(0) && !identity_listed(dev, L::count), "No player, or no list, matched");
|
||||
check(!reskate_banned(dev) && !reskate_banned(stranger), "Someone is banned though the lists ban nobody");
|
||||
check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}, {}, {dev, stranger}}}) && reskate_banned(stranger) &&
|
||||
reskate_banned(dev) && reskate_developer(dev) && !reskate_banned(other_dev) && !reskate_banned(0),
|
||||
"The ban list did not ban exactly the players on it");
|
||||
check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}) && !reskate_banned(stranger), "A lifted ban stayed");
|
||||
check(!publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The same lists counted as a change");
|
||||
// The one mark a player carries: a developer's before a content creator's before a homie's.
|
||||
check(publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}) && identity_mark(dev) == L::developer &&
|
||||
identity_mark(creator) == L::content_creator && identity_mark(homie) == L::homie && !identity_mark(stranger) &&
|
||||
!identity_mark(0),
|
||||
"A player's mark is not the first list they are on");
|
||||
// Centrix: read from the answer as its own list, and its mark comes after a developer's and
|
||||
// before a content creator's.
|
||||
const auto centrix = parse_identity_lists(R"({"categories":{"centrix":["76561198000000005"],"dev":["76561198000000001"]}})");
|
||||
check(centrix[static_cast<std::size_t>(L::centrix)] == std::vector{stranger} && centrix[static_cast<std::size_t>(L::banned)].empty(),
|
||||
"The Centrix list was not read as its own");
|
||||
check(publish_identity_lists({{{dev}, {homie, stranger}, {creator, stranger}, {dev, creator, stranger}}}) &&
|
||||
identity_mark(dev) == L::developer && identity_mark(stranger) == L::centrix && identity_mark(creator) == L::centrix &&
|
||||
identity_mark(homie) == L::homie && !reskate_banned(stranger),
|
||||
"Centrix is not marked after a developer and before a content creator");
|
||||
// Staff: its own list too, marked after a developer and before everyone else.
|
||||
const auto staff = parse_identity_lists(R"({"categories":{"staff":["76561198000000005"],"centrix":["76561198000000005"]}})");
|
||||
check(staff[static_cast<std::size_t>(L::staff)] == std::vector{stranger} && staff[static_cast<std::size_t>(L::centrix)] == std::vector{stranger} &&
|
||||
staff[static_cast<std::size_t>(L::developer)].empty(),
|
||||
"The staff list was not read as its own");
|
||||
check(publish_identity_lists({{{dev}, {homie}, {creator}, {stranger}, {}, {}, {dev, homie, stranger}}}) &&
|
||||
identity_mark(dev) == L::developer && identity_mark(stranger) == L::staff && identity_mark(homie) == L::staff &&
|
||||
identity_mark(creator) == L::content_creator,
|
||||
"Staff is not marked after a developer and before the other lists");
|
||||
{
|
||||
// Their items go green, and the rainbow is a style they (and a developer) can pick: it
|
||||
// travels as a solid colour older builds show, and nobody else's item takes it.
|
||||
const MarkStyle usual{}, rainbow{MarkMode::solid, {0x10, 0x80, 0x38}, rainbow_marker, 0};
|
||||
const auto green = dingosdk::developer_hoodie_detail::item_animation(L::staff, usual);
|
||||
check(green.on && !green.rainbow && green.stops[1][1] > green.stops[1][0] && green.stops[1][1] > green.stops[1][2],
|
||||
"The staff's items are not green");
|
||||
check(valid_mark_style(rainbow) && rainbow_style(rainbow) && !rainbow_style(usual) &&
|
||||
!rainbow_style({MarkMode::gradient, {}, rainbow_marker, 0}),
|
||||
"The rainbow style is not a solid colour with its marker");
|
||||
check(dingosdk::developer_hoodie_detail::item_animation(L::staff, rainbow).rainbow && dingosdk::developer_hoodie_detail::item_animation(L::developer, rainbow).rainbow,
|
||||
"A member of staff or a developer could not pick the rainbow");
|
||||
const auto homies = dingosdk::developer_hoodie_detail::item_animation(L::homie, rainbow);
|
||||
check(homies.on && !homies.rainbow && homies.stops[0] == homies.stops[2], "Someone else's item took the rainbow");
|
||||
}
|
||||
check(publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}), "The lists did not go back");
|
||||
// Their tag and their items are each their own to hide.
|
||||
check(own_tag_shown() && own_items_shown(), "A player's tag or items start hidden");
|
||||
show_own_tag(false);
|
||||
check(!own_tag_shown() && own_items_shown() && identity_mark(dev) == L::developer,
|
||||
"Hiding their own tag did not take, hid their items, or changed the lists");
|
||||
show_own_tag(true);
|
||||
show_own_items(false);
|
||||
check(own_tag_shown() && !own_items_shown(), "Hiding their own items did not take, or hid their tag");
|
||||
show_own_items(true);
|
||||
check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The lists did not go back");
|
||||
// Removing someone in the panel takes their mark away at the next refresh.
|
||||
check(publish_identity_lists({{{dev}, {}, {}}}) && reskate_developer(dev) && !reskate_developer(other_dev) &&
|
||||
!identity_listed(homie, L::homie),
|
||||
"Replaced lists kept a player who was removed");
|
||||
}
|
||||
// The hoodie and board: what a list gives a cosmetic, and what a player makes of it on the Special page.
|
||||
void items() {
|
||||
using namespace dingosdk::developer_hoodie_detail;
|
||||
using dingosdk::multiplayer::MarkMode;
|
||||
using dingosdk::multiplayer::MarkStyle;
|
||||
using dingosdk::multiplayer::MarkStyles;
|
||||
const MarkStyle standard{};
|
||||
const auto spectrum = item_animation(L::developer, standard), red = item_animation(L::content_creator, standard),
|
||||
gold = item_animation(L::homie, standard);
|
||||
check(spectrum.on && spectrum.rainbow && red.on && !red.rainbow && gold.on && !gold.rainbow &&
|
||||
!item_animation(std::nullopt, standard).on,
|
||||
"A list did not give its animation, or a player on none got one");
|
||||
check(item_color(spectrum, 0) == rainbow(0) && item_color(spectrum, 3000) == rainbow(3000), "The developer's rainbow changed");
|
||||
// Red and gold go from a deep shade to a bright one, on into the colour next to it, and back
|
||||
// every three seconds.
|
||||
for (const auto *animation : {&red, &gold}) {
|
||||
const auto deep = item_color(*animation, 0), bright = item_color(*animation, 750), far = item_color(*animation, 1500);
|
||||
check(item_color(*animation, 3000) == deep && item_color(*animation, 4500) == far, "An animation does not repeat every three seconds");
|
||||
check(bright[0] > deep[0] * 2 && far[0] >= bright[0], "An animation does not go from deep to bright");
|
||||
if (animation == &red) {
|
||||
// A content creator's: red, then a reddish pink.
|
||||
check(bright[1] < bright[0] * .1f && bright[2] < bright[0] * .1f, "Red is not red");
|
||||
check(far[2] > bright[2] * 3 && far[2] < far[0] * .4f && far[1] < far[0] * .15f, "Red does not go on into a reddish pink");
|
||||
} else {
|
||||
// A homie's: gold, then yellow.
|
||||
check(bright[1] > bright[0] * .5f && bright[1] < bright[0] * .75f && bright[2] < bright[0] * .15f, "Gold is not gold");
|
||||
check(far[1] > bright[1] * 1.2f && far[1] < far[0] && far[2] < far[1] * .4f, "Gold does not go on into yellow");
|
||||
}
|
||||
for (std::uint64_t at = 0; at < 6000; at += 125) {
|
||||
// The material's gamut, and red always the strongest part of the colour.
|
||||
const auto color = item_color(*animation, at);
|
||||
check(color[0] <= .801f && color[1] > 0 && color[2] > 0 && color[0] > color[1] && color[0] > color[2],
|
||||
"An animation leaves its colours");
|
||||
}
|
||||
}
|
||||
|
||||
// A cosmetic turned off, and one given the player's own two colours.
|
||||
const MarkStyle off{MarkMode::off, {}, {}, 0}, own{MarkMode::gradient, {255, 0, 0}, {0, 0, 255}, 0};
|
||||
check(!item_animation(L::developer, off).on && !item_animation(L::homie, off).on, "A cosmetic turned off still animates");
|
||||
const auto custom = item_animation(L::homie, own);
|
||||
const auto first = item_color(custom, 0), last = item_color(custom, 1500), half = item_color(custom, 750);
|
||||
check(custom.on && !custom.rainbow && first[0] > .7f && first[2] < .01f && last[2] > .7f && last[0] < .01f && half[0] > .3f &&
|
||||
half[2] > .3f && item_color(custom, 3000) == first,
|
||||
"A player's own colours are not what their cosmetic moves between");
|
||||
// A developer can leave the rainbow for colours of their own; no style gives anyone else the rainbow.
|
||||
check(!item_animation(L::developer, own).rainbow && item_animation(L::developer, own).on, "A developer cannot have their own colours");
|
||||
// One colour of their own stands still, at any speed.
|
||||
const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {9, 9, 9}, 2});
|
||||
check(still.on && !still.rainbow && item_color(still, 0) == material_color({0, 255, 0}) && item_color(still, 0)[1] > .7f &&
|
||||
item_color(still, 777) == item_color(still, 0) && item_color(still, 1500) == item_color(still, 0),
|
||||
"A solid colour is not the colour picked, or does not stand still");
|
||||
// A player on no list gets nothing, whatever style their packets carry: the styles only
|
||||
// shape what a list already gives.
|
||||
for (const auto mode : {MarkMode::standard, MarkMode::gradient, MarkMode::solid})
|
||||
for (const std::uint8_t speed : {std::uint8_t{0}, std::uint8_t{2}})
|
||||
check(!item_animation(std::nullopt, {mode, {255, 0, 0}, {0, 0, 255}, speed}).on,
|
||||
"A player on no list got colours by asking for them");
|
||||
for (const auto mode : {MarkMode::standard, MarkMode::gradient, MarkMode::solid})
|
||||
check(!item_animation(L::content_creator, {mode, {}, {}, 0}).rainbow && !item_animation(L::homie, {mode, {}, {}, 0}).rainbow,
|
||||
"A style gave the rainbow to someone who is not a developer");
|
||||
// Slow takes twice as long and fast half, for the rainbow too.
|
||||
check(item_animation(L::homie, {MarkMode::gradient, {}, {}, 1}).period == 6000 &&
|
||||
item_animation(L::homie, {MarkMode::standard, {}, {}, 2}).period == 1500 &&
|
||||
item_animation(L::developer, {MarkMode::standard, {}, {}, 1}).period == 16000 &&
|
||||
item_animation(L::developer, {MarkMode::standard, {}, {}, 2}).period == 4000 &&
|
||||
item_color(item_animation(L::developer, {MarkMode::standard, {}, {}, 2}), 1000) == rainbow(2000),
|
||||
"The speed is not half or twice the usual");
|
||||
// The pickers start from colours close to the list's own.
|
||||
const auto picks = standard_picks(L::content_creator);
|
||||
const auto start = material_color(picks.first), finish = material_color(picks.second);
|
||||
check(std::abs(start[0] - .2f) < .02f && start[1] < .02f && std::abs(finish[0] - .8f) < .01f && std::abs(finish[2] - .13f) < .02f,
|
||||
"A content creator's pickers do not start from their red");
|
||||
|
||||
// The styles are kept in the profile as text.
|
||||
MarkStyles styles{};
|
||||
styles[0] = own, styles[2] = off, styles[4] = {MarkMode::gradient, {1, 2, 3}, {254, 253, 252}, 2};
|
||||
styles.back() = {MarkMode::solid, {7, 8, 9}, {}, 0};
|
||||
const auto text = mark_styles_text(styles);
|
||||
check(text.size() == styles.size() * 16 && parse_mark_styles(text) == styles && parse_mark_styles(mark_styles_text({})) == MarkStyles{},
|
||||
"The styles did not come back from their text");
|
||||
check(!parse_mark_styles("") && !parse_mark_styles("00") && !parse_mark_styles(std::string(text.size(), 'f')) &&
|
||||
!parse_mark_styles(std::string(text.size(), 'g')) && !parse_mark_styles(text + "00") &&
|
||||
!parse_mark_styles(std::string_view(text).substr(16)),
|
||||
"Text that is not styles was read as some");
|
||||
// Every marked cosmetic has a name, the skater's before the board's.
|
||||
check(dingosdk::multiplayer::mark_item_names.size() == slots.size() + dingosdk::developer_board_detail::part_count &&
|
||||
dingosdk::multiplayer::mark_item_names[slots.size()] == "Deck" && slots[2] == "CAS_Footwear_Slot",
|
||||
"The marked cosmetics and their names do not line up");
|
||||
}
|
||||
|
||||
// Whatever a player wears is coloured where the item colours itself, each cosmetic on its own;
|
||||
// turning one off (the Special page, or a list that no longer has the player) puts its own
|
||||
// colours back, and publishes them the extra times the renderer needs to show them.
|
||||
void settling() {
|
||||
using namespace dingosdk::developer_hoodie_detail;
|
||||
using dingosdk::multiplayer::MarkMode;
|
||||
const auto red = item_animation(L::content_creator, {}), gold = item_animation(L::homie, {});
|
||||
const ItemAnimation none;
|
||||
using Skater = std::array<ItemAnimation, slots.size()>;
|
||||
const auto wearing = [&](const ItemAnimation &top, const ItemAnimation &shoes) {
|
||||
Skater out{};
|
||||
out[0] = top, out[2] = shoes;
|
||||
return out;
|
||||
};
|
||||
const Skater bare{};
|
||||
const Color black{.02f, .02f, .02f}, navy{.02f, .02f, .2f}, teal{.02f, .3f, .3f};
|
||||
const auto bound = [](std::uintptr_t item, std::uintptr_t node, std::uint64_t key, const Color &color, std::uint8_t part) {
|
||||
return Binding{item, item + 40, node, {0, sizeof(Color), 1, 1, key}, color, part};
|
||||
};
|
||||
// What the game holds for each colour, read afresh each tick as in game: a top with two
|
||||
// regions it colours and one it leaves to its texture, and shoes that colour none of theirs.
|
||||
std::array<Color, 4> held{black, navy, neutral, neutral};
|
||||
dingosdk::DeveloperHoodieState state;
|
||||
Materials live;
|
||||
live.component = 1, live.appearance = 2, live.regions_only = true;
|
||||
live.bindings = {bound(10, 100, color_keys[0], black, 0), bound(10, 101, color_keys[3], navy, 0),
|
||||
bound(10, 102, color_keys[7], neutral, 0), bound(11, 103, color_keys[0], neutral, 2)};
|
||||
std::vector<std::uintptr_t> published;
|
||||
int writes{};
|
||||
auto write = [&](const Binding &binding, const Color &color) {
|
||||
held[binding.node - 100] = color;
|
||||
++writes;
|
||||
return true;
|
||||
};
|
||||
auto publish = [&](std::uintptr_t item) { published.push_back(item); };
|
||||
const auto tick = [&](const Skater &animations, std::uint64_t at) {
|
||||
for (std::size_t i = 0; i < held.size(); ++i) live.bindings[i].color = held[i];
|
||||
animate(state, live, 5, 6, animations, at, write, publish);
|
||||
};
|
||||
using Items = std::vector<std::uintptr_t>;
|
||||
tick(wearing(red, none), 0);
|
||||
tick(wearing(red, none), 700);
|
||||
check(state.colors.size() == 2 && held[0] == item_color(red, 700) && held[1] == held[0] && held[2] == neutral &&
|
||||
held[3] == neutral && published == Items{10, 10},
|
||||
"The top's own regions were not coloured, or more than them was");
|
||||
// Shoes that colour none of their regions take the colour on all of them.
|
||||
tick(wearing(red, gold), 800);
|
||||
check(held[3] == item_color(gold, 800) && held[2] == neutral && held[0] == item_color(red, 800) && state.colors.size() == 3,
|
||||
"An item with no colours of its own was not coloured, or cosmetics did not each take their own");
|
||||
published.clear();
|
||||
tick(bare, 900);
|
||||
check(held[0] == black && held[1] == navy && held[3] == neutral && published == Items{10, 11},
|
||||
"Turning it off did not bring the items' own colours back");
|
||||
for (std::uint64_t at = 901; at < 911; ++at) tick(bare, at);
|
||||
check(published.size() == 2 * (1 + settle_publishes) && state.colors.empty() && held[0] == black && held[1] == navy,
|
||||
"The own colours were not published the extra times, or the items were not left alone after");
|
||||
// A colour the game itself sets meanwhile is the game's to keep.
|
||||
tick(wearing(gold, none), 1000);
|
||||
held[0] = teal;
|
||||
tick(bare, 1100);
|
||||
check(held[0] == teal && held[1] == navy, "Turning it off overwrote a colour the game had set");
|
||||
// And it comes back on with the own colours still known.
|
||||
for (std::uint64_t at = 1200; at < 1210; ++at) tick(bare, at);
|
||||
tick(wearing(red, none), 2000);
|
||||
tick(bare, 2100);
|
||||
check(held[0] == teal && held[1] == navy, "A second round lost the top's own colours");
|
||||
for (std::uint64_t at = 2200; at < 2210; ++at) tick(bare, at);
|
||||
|
||||
// A colour that stands still is written once, and published until the renderer shows it.
|
||||
const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {}, 0});
|
||||
published.clear(), writes = 0;
|
||||
for (std::uint64_t at = 3000; at < 3010; ++at) tick(wearing(still, none), at);
|
||||
check(held[0] == item_color(still, 0) && held[1] == held[0] && writes == 2 && published == Items(1 + settle_publishes, 10),
|
||||
"A solid colour was written again and again, or not published until it showed");
|
||||
// While a recipe is on its way nothing is touched, and what was saved is kept.
|
||||
live.pending = true;
|
||||
tick(bare, 4000);
|
||||
check(held[0] == item_color(still, 0) && state.colors.size() == 2 && writes == 2, "A pending recipe was written to");
|
||||
live.pending = false;
|
||||
tick(bare, 4100);
|
||||
check(held[0] == teal && held[1] == navy, "The own colours were lost while a recipe was pending");
|
||||
|
||||
// The board does the same, each of its parts on its own: here the deck and the wheels, whose
|
||||
// material has no colour of its own until one is added.
|
||||
namespace board = dingosdk::developer_board_detail;
|
||||
std::array<Color, 2> painted{black, board::shader_default};
|
||||
dingosdk::DeveloperBoardState deck;
|
||||
Materials parts;
|
||||
parts.component = 1, parts.appearance = 2;
|
||||
parts.bindings = {Binding{30, 10, 100, board::parameter_key(board::base_color, 1), black, 0},
|
||||
Binding{31, 11, 0, board::parameter_key(board::base_color, 1), board::shader_default, 3}};
|
||||
int shown{};
|
||||
bool added{};
|
||||
auto paint = [&](const Binding &binding, const Color &color) {
|
||||
if (!binding.node) added = true; // the engine adds the missing parameter
|
||||
painted[binding.part ? 1 : 0] = color;
|
||||
return true;
|
||||
};
|
||||
auto show = [&](std::uintptr_t) { ++shown; };
|
||||
using Board = std::array<ItemAnimation, board::part_count>;
|
||||
const auto roll = [&](const ItemAnimation &on_deck, const ItemAnimation &on_wheels, std::uint64_t at) {
|
||||
parts.bindings[0].color = painted[0], parts.bindings[1].color = painted[1];
|
||||
parts.bindings[1].node = added ? 101 : 0;
|
||||
animate(deck, parts, 5, 6, Board{on_deck, none, none, on_wheels}, at, paint, show);
|
||||
};
|
||||
roll(gold, red, 0);
|
||||
roll(gold, red, 700);
|
||||
check(deck.colors.size() == 2 && added && painted[0] == item_color(gold, 700) && painted[1] == item_color(red, 700),
|
||||
"The board's parts did not each take their own animation, or a missing colour was not added");
|
||||
// The wheels alone turned off: back to the shaders' default, while the deck goes on.
|
||||
for (std::uint64_t at = 800; at < 810; ++at) roll(gold, none, at);
|
||||
check(painted[0] == item_color(gold, 809) && painted[1] == board::shader_default && deck.colors.size() == 1,
|
||||
"Turning one part off stopped another, or did not stop it");
|
||||
shown = 0;
|
||||
for (std::uint64_t at = 900; at < 910; ++at) roll(none, none, at);
|
||||
check(painted[0] == black && painted[1] == board::shader_default && shown == 1 + settle_publishes && deck.colors.empty(),
|
||||
"Turning it off did not bring the board's own colours back and publish them the extra times");
|
||||
}
|
||||
|
||||
// --live <SteamID64>: the deployed backend, read the way the game reads it. Not
|
||||
// part of the test run, which stays off the network.
|
||||
int live(const char *player) {
|
||||
const auto id = std::strtoull(player, nullptr, 10);
|
||||
for (int waited = 0; waited < 150 && !reskate_developer(id); ++waited) {
|
||||
refresh_identity_lists();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
std::cout << player << (reskate_developer(id) ? " is" : " is not") << " a developer in the lists the backend sent\n";
|
||||
return reskate_developer(id) ? 0 : 1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int count, char **arguments) {
|
||||
if (count == 3 && std::string_view(arguments[1]) == "--live") return live(arguments[2]);
|
||||
parsing();
|
||||
lookup();
|
||||
items();
|
||||
settling();
|
||||
std::cout << "developer identity tests passed\n";
|
||||
}
|
||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user